From 63133da0b288d664ce5446c785696e7e139ae60b Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Fri, 11 Sep 2026 15:59:34 +0530 Subject: [PATCH 1/5] =?UTF-8?q?Tighten=20NameSchema=20to=20hyphen-only=202?= =?UTF-8?q?=E2=80=9364=20and=20migrate=20legacy=20./=5F=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/20260911-name-hyphen-only.md | 5 + .../src/db/applyNameHyphenOnlyMigration.ts | 448 ++++++++++++++++++ .../trueforge/src/db/planHyphenOnlyRenames.ts | 143 ++++++ .../20260911_000001_name_hyphen_only.ts | 28 ++ .../20260911_000001_name_hyphen_only.ts | 30 ++ packages/trueforge/src/schemas/common.ts | 14 +- .../trueforge/tests/unit/apis/agents.test.ts | 6 + .../unit/db/planHyphenOnlyRenames.test.ts | 59 +++ .../20260911_000001_name_hyphen_only.test.ts | 193 ++++++++ .../tests/unit/schemas/nameSchema.test.ts | 19 + .../truefoundry/trueFoundryNaming.test.ts | 319 +++++++++++++ 11 files changed, 1260 insertions(+), 4 deletions(-) create mode 100644 .changeset/20260911-name-hyphen-only.md create mode 100644 packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts create mode 100644 packages/trueforge/src/db/planHyphenOnlyRenames.ts create mode 100644 packages/trueforge/src/db/postgres/migrations/20260911_000001_name_hyphen_only.ts create mode 100644 packages/trueforge/src/db/sqlite/migrations/20260911_000001_name_hyphen_only.ts create mode 100644 packages/trueforge/tests/unit/db/planHyphenOnlyRenames.test.ts create mode 100644 packages/trueforge/tests/unit/db/sqlite/migrations/20260911_000001_name_hyphen_only.test.ts create mode 100644 packages/trueforge/tests/unit/schemas/nameSchema.test.ts create mode 100644 packages/trueforge/tests/unit/truefoundry/trueFoundryNaming.test.ts diff --git a/.changeset/20260911-name-hyphen-only.md b/.changeset/20260911-name-hyphen-only.md new file mode 100644 index 000000000..650a42d87 --- /dev/null +++ b/.changeset/20260911-name-hyphen-only.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge': patch +--- + +Constrain ResourceName (NameSchema) to hyphen-only 2–64 and migrate existing "."/"_" names. diff --git a/packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts b/packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts new file mode 100644 index 000000000..55ca7a4a0 --- /dev/null +++ b/packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts @@ -0,0 +1,448 @@ +/** + * One-time migration: change old names with `.` or `_` into hyphen-only names. + * + * Steps: + * 1. Rename model providers (and model names inside their manifest). + * 2. Rename skills and MCP servers (and their manifest.name). + * 3. Rename schedules. + * 4. Rename agents, then update session/schedule.agent_name to match. + * 5. Fix agent.manifest / session.agent_spec so they still point at the new names. + * + * Always read JSON with dialect.readJson (especially on SQLite). Cannot undo. + */ +import { sql, type Kysely, type RawBuilder } from 'kysely'; +import { + buildRenameMap, + planHyphenOnlyName, + planHyphenOnlyRenames, + rewriteAgentSpecNameRefs, + type NameRename, +} from './planHyphenOnlyRenames'; + +export interface NameHyphenMigrationDialect { + bindJson(value: unknown): RawBuilder; + touchUpdatedAt(): RawBuilder; + /** How to read a JSON column (SQLite needs json(column)). */ + readJson(column: 'manifest' | 'agent_spec'): RawBuilder; +} + +/** Log how many rows we scanned and how many we changed. */ +function logRenameProgress({ kind, total, updated }: { kind: string; total: number; updated: number }): void { + console.log(`name-hyphen-only migration [${kind}]: total=${String(total)} updated=${String(updated)}`); +} + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Turn a readJson result into a plain object. + * Postgres often gives an object; SQLite may give a JSON string. Always SELECT with + * readJson — do not read raw binary JSON. + */ +function asRecord(value: unknown): Record | undefined { + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value); + return isPlainRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } + } + return isPlainRecord(value) ? value : undefined; +} + +async function rewriteAgentDocuments({ + db, + dialect, + providers, + models, + mcpServers, + skills, +}: { + db: Kysely; + dialect: NameHyphenMigrationDialect; + providers: ReadonlyMap; + models: ReadonlyMap; + mcpServers: ReadonlyMap; + skills: ReadonlyMap; +}): Promise<{ agentManifest: number; sessionAgentSpec: number }> { + if (providers.size === 0 && models.size === 0 && mcpServers.size === 0 && skills.size === 0) { + return { agentManifest: 0, sessionAgentSpec: 0 }; + } + + const agents = await sql<{ id: string; tenant_id: string; manifest: unknown }>` + SELECT id, tenant_id, ${dialect.readJson('manifest')} AS manifest FROM agent + `.execute(db); + + let agentUpdated = 0; + for (const row of agents.rows) { + const manifest = asRecord(row.manifest); + if (manifest === undefined) { + continue; + } + const next = rewriteAgentSpecNameRefs({ + spec: manifest, + tenantId: row.tenant_id, + providers, + models, + mcpServers, + skills, + }); + if (JSON.stringify(next) === JSON.stringify(manifest)) { + continue; + } + await sql` + UPDATE agent + SET manifest = ${dialect.bindJson(next)}, updated_at = ${dialect.touchUpdatedAt()} + WHERE id = ${row.id} + `.execute(db); + agentUpdated += 1; + } + logRenameProgress({ + kind: 'agent.manifest', + total: agents.rows.length, + updated: agentUpdated, + }); + + const sessions = await sql<{ session_id: string; tenant_id: string; agent_spec: unknown }>` + SELECT session_id, tenant_id, ${dialect.readJson('agent_spec')} AS agent_spec + FROM session WHERE agent_spec IS NOT NULL + `.execute(db); + + let sessionUpdated = 0; + for (const row of sessions.rows) { + const spec = asRecord(row.agent_spec); + if (spec === undefined) { + continue; + } + const next = rewriteAgentSpecNameRefs({ + spec, + tenantId: row.tenant_id, + providers, + models, + mcpServers, + skills, + }); + if (JSON.stringify(next) === JSON.stringify(spec)) { + continue; + } + await sql` + UPDATE session + SET agent_spec = ${dialect.bindJson(next)} + WHERE tenant_id = ${row.tenant_id} AND session_id = ${row.session_id} + `.execute(db); + sessionUpdated += 1; + } + logRenameProgress({ + kind: 'session.agent_spec', + total: sessions.rows.length, + updated: sessionUpdated, + }); + return { agentManifest: agentUpdated, sessionAgentSpec: sessionUpdated }; +} + +async function renameSkillRows({ + db, + dialect, + renames, +}: { + db: Kysely; + dialect: NameHyphenMigrationDialect; + renames: readonly NameRename[]; +}): Promise { + let updated = 0; + for (const rename of renames) { + const rows = await sql<{ manifest: unknown }>` + SELECT ${dialect.readJson('manifest')} AS manifest FROM skill + WHERE tenant_id = ${rename.tenant_id} AND name = ${rename.from} + `.execute(db); + const manifest = asRecord(rows.rows[0]?.manifest); + if (manifest === undefined) { + throw new Error(`Missing skill row ${rename.tenant_id}/${rename.from} during name migration`); + } + await sql` + UPDATE skill + SET + name = ${rename.to}, + manifest = ${dialect.bindJson({ ...manifest, name: rename.to })}, + updated_at = ${dialect.touchUpdatedAt()} + WHERE tenant_id = ${rename.tenant_id} AND name = ${rename.from} + `.execute(db); + updated += 1; + } + return updated; +} + +async function renameMcpServerRows({ + db, + dialect, + renames, +}: { + db: Kysely; + dialect: NameHyphenMigrationDialect; + renames: readonly NameRename[]; +}): Promise { + let updated = 0; + for (const rename of renames) { + const rows = await sql<{ manifest: unknown }>` + SELECT ${dialect.readJson('manifest')} AS manifest FROM mcp_server + WHERE tenant_id = ${rename.tenant_id} AND name = ${rename.from} + `.execute(db); + const manifest = asRecord(rows.rows[0]?.manifest); + if (manifest === undefined) { + throw new Error(`Missing mcp_server row ${rename.tenant_id}/${rename.from} during name migration`); + } + await sql` + UPDATE mcp_server + SET + name = ${rename.to}, + manifest = ${dialect.bindJson({ ...manifest, name: rename.to })}, + updated_at = ${dialect.touchUpdatedAt()} + WHERE tenant_id = ${rename.tenant_id} AND name = ${rename.from} + `.execute(db); + updated += 1; + } + return updated; +} + +/** Rename schedule rows by id (name column only). */ +async function renameScheduleRows({ + db, + dialect, + renames, +}: { + db: Kysely; + dialect: NameHyphenMigrationDialect; + renames: readonly { id: string; to: string }[]; +}): Promise { + let updated = 0; + for (const rename of renames) { + await sql` + UPDATE schedule + SET name = ${rename.to}, updated_at = ${dialect.touchUpdatedAt()} + WHERE id = ${rename.id} + `.execute(db); + updated += 1; + } + return updated; +} + +/** Rename agents and update matching session/schedule.agent_name. */ +async function renameAgentRows({ + db, + dialect, + renames, +}: { + db: Kysely; + dialect: NameHyphenMigrationDialect; + renames: readonly { id: string; tenant_id: string; from: string; to: string }[]; +}): Promise { + let updated = 0; + for (const rename of renames) { + await sql` + UPDATE agent + SET name = ${rename.to}, updated_at = ${dialect.touchUpdatedAt()} + WHERE id = ${rename.id} + `.execute(db); + await sql` + UPDATE session + SET agent_name = ${rename.to} + WHERE tenant_id = ${rename.tenant_id} AND agent_name = ${rename.from} + `.execute(db); + await sql` + UPDATE schedule + SET agent_name = ${rename.to} + WHERE tenant_id = ${rename.tenant_id} AND agent_name = ${rename.from} + `.execute(db); + updated += 1; + } + return updated; +} + +export async function applyNameHyphenOnlyMigration({ + db, + dialect, +}: { + db: Kysely; + dialect: NameHyphenMigrationDialect; +}): Promise { + // Step 1 — model providers (and remember renames for step 5). + const providerRows = await sql<{ tenant_id: string; name: string; manifest: unknown }>` + SELECT tenant_id, name, ${dialect.readJson('manifest')} AS manifest FROM model_provider + `.execute(db); + + const providerRenames: NameRename[] = []; + const modelRenameMap = new Map(); + let providerUpdated = 0; + let nestedModelsRenamed = 0; + + for (const row of providerRows.rows) { + const providerTo = planHyphenOnlyName(row.name) ?? row.name; + if (providerTo !== row.name) { + providerRenames.push({ tenant_id: row.tenant_id, from: row.name, to: providerTo }); + } + + const manifest = asRecord(row.manifest); + if (manifest === undefined) { + continue; + } + const modelsValue = manifest['models']; + let modelsChanged = false; + let nextModels: unknown = modelsValue; + if (Array.isArray(modelsValue)) { + const rewritten: unknown[] = []; + for (const raw of modelsValue) { + const model = asRecord(raw); + if (model === undefined || typeof model['name'] !== 'string') { + rewritten.push(raw); + continue; + } + const modelName = model['name']; + // Model display names must follow NameSchema too. + const modelTo = planHyphenOnlyName(modelName); + if (modelTo === undefined) { + rewritten.push(model); + continue; + } + // Remember old→new so step 5 can fix provider/model refs. + modelRenameMap.set(`${row.tenant_id}\0${row.name}\0${modelName}`, modelTo); + modelsChanged = true; + nestedModelsRenamed += 1; + rewritten.push({ ...model, name: modelTo }); + } + nextModels = rewritten; + } + + const nextManifest: Record = { ...manifest }; + if (modelsChanged) { + nextManifest['models'] = nextModels; + } + // Custom providers also store the name inside manifest.name — keep it matching. + if (manifest['type'] === 'custom' && typeof manifest['name'] === 'string' && providerTo !== row.name) { + nextManifest['name'] = providerTo; + } + + if (providerTo === row.name && !modelsChanged) { + continue; + } + + await sql` + UPDATE model_provider + SET + name = ${providerTo}, + manifest = ${dialect.bindJson(nextManifest)}, + updated_at = ${dialect.touchUpdatedAt()} + WHERE tenant_id = ${row.tenant_id} AND name = ${row.name} + `.execute(db); + providerUpdated += 1; + } + logRenameProgress({ + kind: 'model_provider', + total: providerRows.rows.length, + updated: providerUpdated, + }); + if (nestedModelsRenamed > 0) { + logRenameProgress({ + kind: 'model_provider.models', + total: nestedModelsRenamed, + updated: nestedModelsRenamed, + }); + } + + // Step 2 — skills and MCP servers. + const skillRows = await sql<{ tenant_id: string; name: string }>` + SELECT tenant_id, name FROM skill + `.execute(db); + const skillRenames = planHyphenOnlyRenames(skillRows.rows); + const skillUpdated = await renameSkillRows({ db, dialect, renames: skillRenames }); + logRenameProgress({ + kind: 'skill', + total: skillRows.rows.length, + updated: skillUpdated, + }); + + const mcpRows = await sql<{ tenant_id: string; name: string }>` + SELECT tenant_id, name FROM mcp_server + `.execute(db); + const mcpRenames = planHyphenOnlyRenames(mcpRows.rows); + const mcpUpdated = await renameMcpServerRows({ db, dialect, renames: mcpRenames }); + logRenameProgress({ + kind: 'mcp_server', + total: mcpRows.rows.length, + updated: mcpUpdated, + }); + + // Step 3 — schedule names (agent_name is updated in step 4). + const scheduleRows = await sql<{ id: string; tenant_id: string; name: string }>` + SELECT id, tenant_id, name FROM schedule + `.execute(db); + const scheduleRenames: { id: string; to: string }[] = []; + for (const row of scheduleRows.rows) { + const to = planHyphenOnlyName(row.name); + if (to === undefined) { + continue; + } + scheduleRenames.push({ id: row.id, to }); + } + const scheduleUpdated = await renameScheduleRows({ db, dialect, renames: scheduleRenames }); + logRenameProgress({ + kind: 'schedule.name', + total: scheduleRows.rows.length, + updated: scheduleUpdated, + }); + + // Step 4 — agents (and session/schedule.agent_name). + const agentRows = await sql<{ id: string; tenant_id: string; name: string }>` + SELECT id, tenant_id, name FROM agent + `.execute(db); + const agentRenames: { id: string; tenant_id: string; from: string; to: string }[] = []; + for (const row of agentRows.rows) { + const to = planHyphenOnlyName(row.name); + if (to === undefined) { + continue; + } + agentRenames.push({ id: row.id, tenant_id: row.tenant_id, from: row.name, to }); + } + const agentUpdated = await renameAgentRows({ db, dialect, renames: agentRenames }); + logRenameProgress({ + kind: 'agent', + total: agentRows.rows.length, + updated: agentUpdated, + }); + + // Step 5 — fix agent.manifest / session.agent_spec that still use old names. + const agentSpecUpdates = await rewriteAgentDocuments({ + db, + dialect, + providers: buildRenameMap(providerRenames), + models: modelRenameMap, + mcpServers: buildRenameMap(mcpRenames), + skills: buildRenameMap(skillRenames), + }); + + const totalUpdated = + providerUpdated + + nestedModelsRenamed + + skillUpdated + + mcpUpdated + + scheduleUpdated + + agentUpdated + + agentSpecUpdates.agentManifest + + agentSpecUpdates.sessionAgentSpec; + if (totalUpdated === 0) { + console.log('name-hyphen-only migration: nothing to rename'); + } else { + console.log( + `name-hyphen-only migration: done updated=${String(totalUpdated)} ` + + `model_provider=${String(providerUpdated)} ` + + `model_provider.models=${String(nestedModelsRenamed)} ` + + `skill=${String(skillUpdated)} ` + + `mcp_server=${String(mcpUpdated)} ` + + `schedule.name=${String(scheduleUpdated)} ` + + `agent=${String(agentUpdated)} ` + + `agent.manifest=${String(agentSpecUpdates.agentManifest)} ` + + `session.agent_spec=${String(agentSpecUpdates.sessionAgentSpec)}`, + ); + } +} diff --git a/packages/trueforge/src/db/planHyphenOnlyRenames.ts b/packages/trueforge/src/db/planHyphenOnlyRenames.ts new file mode 100644 index 000000000..1af9eabb4 --- /dev/null +++ b/packages/trueforge/src/db/planHyphenOnlyRenames.ts @@ -0,0 +1,143 @@ +/** + * Decide the new hyphen-only name for an old local name. + * + * Only renames old-style names that still contain `.` or `_`. + * Skips names that are already fine, and skips FQNs / other non-names. + * + * New name: replace `.`/`_` with `-`, add a short random suffix, keep length ≤ 64. + */ +import { randomBytes } from 'node:crypto'; + +/** Old name pattern (allowed `.` `_` `-`). */ +export const LEGACY_NAME_SCHEMA_RE = /^[a-z](?:[a-z0-9._-]{0,62}[a-z0-9])$/; + +/** New name pattern (hyphens only). */ +export const HYPHEN_ONLY_NAME_RE = /^[a-z][a-z0-9-]{0,62}[a-z0-9]$/; + +function isJsonObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export interface NamedRow { + tenant_id: string; + name: string; +} + +export interface NameRename { + tenant_id: string; + from: string; + to: string; +} + +const MAX_LEN = 64; + +/** New name, or undefined if this string should not be renamed. */ +export function planHyphenOnlyName(name: string): string | undefined { + // Skip if not an old-style name, or if it is already hyphen-only. + if (!LEGACY_NAME_SCHEMA_RE.test(name) || HYPHEN_ONLY_NAME_RE.test(name)) { + return undefined; + } + const suffix = `-${randomBytes(2).toString('hex')}`; + const base = name + .replace(/[._]/g, '-') + .slice(0, MAX_LEN - suffix.length) + .replace(/-+$/, ''); + return `${base}${suffix}`; +} + +export function planHyphenOnlyRenames(rows: readonly NamedRow[]): NameRename[] { + const renames: NameRename[] = []; + for (const row of rows) { + const to = planHyphenOnlyName(row.name); + if (to === undefined) { + continue; + } + renames.push({ tenant_id: row.tenant_id, from: row.name, to }); + } + return renames; +} + +export function renameMapKey({ tenantId, name }: { tenantId: string; name: string }): string { + return `${tenantId}\0${name}`; +} + +export function buildRenameMap(renames: readonly NameRename[]): Map { + const map = new Map(); + for (const rename of renames) { + map.set(renameMapKey({ tenantId: rename.tenant_id, name: rename.from }), rename.to); + } + return map; +} + +/** + * Update names inside agent.manifest / session.agent_spec after DB columns were renamed. + * Fixes model (`provider/model`), MCP, and skill refs. Unknown strings stay as-is. + */ +export function rewriteAgentSpecNameRefs({ + spec, + tenantId, + providers, + models, + mcpServers, + skills, +}: { + spec: Record; + tenantId: string; + providers: ReadonlyMap; + /** Map of old model name → new model name (per tenant + provider). */ + models: ReadonlyMap; + mcpServers: ReadonlyMap; + skills: ReadonlyMap; +}): Record { + const next: Record = { ...spec }; + + const model = spec['model']; + if (isJsonObject(model)) { + const modelObj: Record = { ...model }; + const fqn = modelObj['name']; + if (typeof fqn === 'string') { + const slash = fqn.indexOf('/'); + if (slash > 0) { + const providerFrom = fqn.slice(0, slash); + const modelFrom = fqn.slice(slash + 1); + const providerTo = providers.get(renameMapKey({ tenantId, name: providerFrom })) ?? providerFrom; + const modelTo = + models.get(`${tenantId}\0${providerFrom}\0${modelFrom}`) ?? + models.get(`${tenantId}\0${providerTo}\0${modelFrom}`) ?? + modelFrom; + modelObj['name'] = `${providerTo}/${modelTo}`; + } + } + next['model'] = modelObj; + } + + const mcp = spec['mcp_servers']; + if (Array.isArray(mcp)) { + next['mcp_servers'] = mcp.map((entry: unknown): unknown => { + if (!isJsonObject(entry)) { + return entry; + } + const row: Record = { ...entry }; + if (typeof row['name'] === 'string') { + row['name'] = mcpServers.get(renameMapKey({ tenantId, name: row['name'] })) ?? row['name']; + } + return row; + }); + } + + const skillList = spec['skills']; + if (Array.isArray(skillList)) { + next['skills'] = skillList.map((entry: unknown): unknown => { + if (!isJsonObject(entry)) { + return entry; + } + const row: Record = { ...entry }; + if (typeof row['name'] === 'string') { + row['name'] = skills.get(renameMapKey({ tenantId, name: row['name'] })) ?? row['name']; + } + return row; + }); + } + + return next; +} diff --git a/packages/trueforge/src/db/postgres/migrations/20260911_000001_name_hyphen_only.ts b/packages/trueforge/src/db/postgres/migrations/20260911_000001_name_hyphen_only.ts new file mode 100644 index 000000000..7412e2276 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260911_000001_name_hyphen_only.ts @@ -0,0 +1,28 @@ +import { sql, type Kysely } from 'kysely'; +import { applyNameHyphenOnlyMigration } from '../../applyNameHyphenOnlyMigration'; + +/** + * Postgres migration: rename old `.`/`_` names to hyphen-only. + * Shared logic lives in applyNameHyphenOnlyMigration.ts. Cannot undo. + */ +export async function up(db: Kysely): Promise { + try { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await applyNameHyphenOnlyMigration({ + db, + dialect: { + bindJson: value => sql`${JSON.stringify(value)}::jsonb`, + touchUpdatedAt: () => sql`now()`, + readJson: column => sql.ref(column), + }, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Failed NameSchema hyphen-only migration: ${detail}`, { cause: error }); + } +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + return Promise.reject(new Error(`This migration is not reversible`)); +} diff --git a/packages/trueforge/src/db/sqlite/migrations/20260911_000001_name_hyphen_only.ts b/packages/trueforge/src/db/sqlite/migrations/20260911_000001_name_hyphen_only.ts new file mode 100644 index 000000000..da5d6bee8 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260911_000001_name_hyphen_only.ts @@ -0,0 +1,30 @@ +import { sql, type Kysely } from 'kysely'; +import { applyNameHyphenOnlyMigration } from '../../applyNameHyphenOnlyMigration'; + +/** + * SQLite migration: same as Postgres (applyNameHyphenOnlyMigration.ts). + * Uses json(column) when reading JSON so binary blobs are not corrupted. Cannot undo. + */ +export async function up(db: Kysely): Promise { + try { + const now = new Date().toISOString(); + await db.transaction().execute(async trx => { + await applyNameHyphenOnlyMigration({ + db: trx, + dialect: { + bindJson: value => sql`jsonb(${JSON.stringify(value)})`, + touchUpdatedAt: () => sql`${now}`, + readJson: column => sql`json(${sql.ref(column)})`, + }, + }); + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Failed NameSchema hyphen-only migration: ${detail}`, { cause: error }); + } +} + +export async function down(db: Kysely): Promise { + void db; + return Promise.reject(new Error(`This migration is not reversible`)); +} diff --git a/packages/trueforge/src/schemas/common.ts b/packages/trueforge/src/schemas/common.ts index 0c78868d8..f2b37e39d 100644 --- a/packages/trueforge/src/schemas/common.ts +++ b/packages/trueforge/src/schemas/common.ts @@ -4,16 +4,22 @@ import { z } from '@hono/zod-openapi'; /** - * Lowercase name, 2–64 chars: starts with a letter, ends with alphanumeric, - * may contain ".", "_" or "-" in between (aligned with SF model-integration names). + * Shared local resource name (agents, skills, MCP, models, schedules). + * 2–64 lowercase chars, letter start, letter/digit end, hyphens only (matches SVC). + * + * - TrueFoundry mode: agent names follow this rule (synced to SVC). Skills/MCP/models + * come from SVC as FQNs or longer names and are not checked with NameSchema; AgentSpec + * refs stay plain strings. Schedules are local and still use this schema. + * - Standalone mode: all of those resources are created locally under this schema. + * - DB migration: rewrites old local names that used `.`/`_` only. */ export const NameSchema = z .string() .min(2) .max(64) .regex( - /^[a-z](?:[a-z0-9._-]{0,62}[a-z0-9])$/, - 'must be 2–64 lowercase chars: start with a letter, end with alphanumeric, optionally separated by ".", "_" or "-"', + /^[a-z][a-z0-9-]{0,62}[a-z0-9]$/, + 'must be 2–64 lowercase chars: start with a letter, end with alphanumeric, hyphens only in between', ) .openapi('ResourceName'); diff --git a/packages/trueforge/tests/unit/apis/agents.test.ts b/packages/trueforge/tests/unit/apis/agents.test.ts index edf2ca0f7..a104b4dc7 100644 --- a/packages/trueforge/tests/unit/apis/agents.test.ts +++ b/packages/trueforge/tests/unit/apis/agents.test.ts @@ -213,6 +213,12 @@ describe('agents router', () => { const badName = await router.request('/', jsonInit('POST', { ...writeBody, name: 'Not A Name' })); expect(badName.status).toBe(400); + const dotted = await router.request('/', jsonInit('POST', { ...writeBody, name: 'my.agent' })); + expect(dotted.status).toBe(400); + + const underscored = await router.request('/', jsonInit('POST', { ...writeBody, name: 'my_agent' })); + expect(underscored.status).toBe(400); + const reservedTfg = await router.request('/', jsonInit('POST', { ...writeBody, name: 'tfg' })); expect(reservedTfg.status).toBe(400); diff --git a/packages/trueforge/tests/unit/db/planHyphenOnlyRenames.test.ts b/packages/trueforge/tests/unit/db/planHyphenOnlyRenames.test.ts new file mode 100644 index 000000000..91ebb0d2b --- /dev/null +++ b/packages/trueforge/tests/unit/db/planHyphenOnlyRenames.test.ts @@ -0,0 +1,59 @@ +import { + buildRenameMap, + planHyphenOnlyName, + planHyphenOnlyRenames, + rewriteAgentSpecNameRefs, +} from '../../../src/db/planHyphenOnlyRenames'; +import { NameSchema } from '../../../src/schemas/common'; + +describe('planHyphenOnlyRenames', () => { + it('hyphenates legacy NameSchema values and appends a random suffix', () => { + const renames = planHyphenOnlyRenames([{ tenant_id: 't', name: 'my.agent_name' }]); + expect(renames).toHaveLength(1); + expect(renames[0]).toMatchObject({ tenant_id: 't', from: 'my.agent_name' }); + expect(renames[0]?.to).toMatch(/^my-agent-name-[0-9a-f]{4}$/); + expect(NameSchema.safeParse(renames[0]?.to).success).toBe(true); + }); + + it('skips hyphen-only names and non-NameSchema strings (e.g. FQNs)', () => { + expect(planHyphenOnlyRenames([{ tenant_id: 't', name: 'my-agent' }])).toEqual([]); + expect(planHyphenOnlyName('repo/skill:1')).toBeUndefined(); + expect(planHyphenOnlyName('openai')).toBeUndefined(); + // TrueFoundry catalog strings — must not be rewritten as local resource names. + expect(planHyphenOnlyName('agent-skill:acme/team_a/my.echo:3')).toBeUndefined(); + expect(planHyphenOnlyName('openai/gpt-5.2')).toBeUndefined(); + expect(planHyphenOnlyName(`a${'b'.repeat(71)}`)).toBeUndefined(); + }); + + it('truncates so the random suffix still fits in 64 characters', () => { + const from = `a${'b'.repeat(61)}.b`; + expect(from).toHaveLength(64); + const to = planHyphenOnlyName(from); + expect(to?.length).toBeLessThanOrEqual(64); + expect(NameSchema.safeParse(to).success).toBe(true); + }); +}); + +describe('rewriteAgentSpecNameRefs', () => { + it('rewrites model FQN, mcp, and skill refs from rename maps', () => { + const tenantId = 't'; + const next = rewriteAgentSpecNameRefs({ + spec: { + model: { name: 'my.prov/my.model' }, + mcp_servers: [{ name: 'my.mcp' }], + skills: [{ name: 'my.skill' }, { name: 'repo/skill:1' }], + }, + tenantId, + providers: buildRenameMap([{ tenant_id: tenantId, from: 'my.prov', to: 'my-prov-aaaa' }]), + models: new Map([[`${tenantId}\0my.prov\0my.model`, 'my-model-bbbb']]), + mcpServers: buildRenameMap([{ tenant_id: tenantId, from: 'my.mcp', to: 'my-mcp-cccc' }]), + skills: buildRenameMap([{ tenant_id: tenantId, from: 'my.skill', to: 'my-skill-dddd' }]), + }); + + expect(next).toEqual({ + model: { name: 'my-prov-aaaa/my-model-bbbb' }, + mcp_servers: [{ name: 'my-mcp-cccc' }], + skills: [{ name: 'my-skill-dddd' }, { name: 'repo/skill:1' }], + }); + }); +}); diff --git a/packages/trueforge/tests/unit/db/sqlite/migrations/20260911_000001_name_hyphen_only.test.ts b/packages/trueforge/tests/unit/db/sqlite/migrations/20260911_000001_name_hyphen_only.test.ts new file mode 100644 index 000000000..c13421f08 --- /dev/null +++ b/packages/trueforge/tests/unit/db/sqlite/migrations/20260911_000001_name_hyphen_only.test.ts @@ -0,0 +1,193 @@ +import { sql } from 'kysely'; +import { createSqliteDb } from '../../../../../src/db/sqlite/client'; +import { up } from '../../../../../src/db/sqlite/migrations/20260911_000001_name_hyphen_only'; + +describe('SQLite name hyphen-only migration', () => { + it('rewrites dotted names without destroying JSONB manifests', async () => { + const db = createSqliteDb(':memory:'); + try { + await sql` + CREATE TABLE model_provider ( + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + updated_at TEXT NOT NULL + ) STRICT + `.execute(db); + await sql` + CREATE TABLE skill ( + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + updated_at TEXT NOT NULL + ) STRICT + `.execute(db); + await sql` + CREATE TABLE mcp_server ( + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + updated_at TEXT NOT NULL + ) STRICT + `.execute(db); + await sql` + CREATE TABLE schedule ( + id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + agent_name TEXT, + updated_at TEXT NOT NULL + ) STRICT + `.execute(db); + await sql` + CREATE TABLE agent ( + id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + updated_at TEXT NOT NULL + ) STRICT + `.execute(db); + await sql` + CREATE TABLE session ( + session_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + agent_name TEXT, + agent_spec BLOB + ) STRICT + `.execute(db); + + const now = '2026-09-11T00:00:00.000Z'; + await sql` + INSERT INTO model_provider (tenant_id, name, manifest, updated_at) + VALUES ( + 't', + 'my.prov', + jsonb(${JSON.stringify({ + type: 'custom', + name: 'my.prov', + models: [{ name: 'my.model', model_id: 'upstream' }], + })}), + ${now} + ) + `.execute(db); + await sql` + INSERT INTO skill (tenant_id, name, manifest, updated_at) + VALUES ( + 't', + 'my.skill', + jsonb(${JSON.stringify({ type: 'git', name: 'my.skill', url: 'https://example.com/s.git' })}), + ${now} + ) + `.execute(db); + await sql` + INSERT INTO mcp_server (tenant_id, name, manifest, updated_at) + VALUES ( + 't', + 'my.mcp', + jsonb(${JSON.stringify({ type: 'http', name: 'my.mcp', url: 'https://example.com/mcp' })}), + ${now} + ) + `.execute(db); + await sql` + INSERT INTO agent (id, tenant_id, name, manifest, updated_at) + VALUES ( + 'a1', + 't', + 'my.agent', + jsonb(${JSON.stringify({ + model: { name: 'my.prov/my.model' }, + mcp_servers: [{ name: 'my.mcp' }], + skills: [{ name: 'my.skill' }], + })}), + ${now} + ) + `.execute(db); + await sql` + INSERT INTO session (session_id, tenant_id, agent_name, agent_spec) + VALUES ( + 's1', + 't', + 'my.agent', + jsonb(${JSON.stringify({ + model: { name: 'my.prov/my.model' }, + mcp_servers: [{ name: 'my.mcp' }], + skills: [{ name: 'my.skill' }], + })}) + ) + `.execute(db); + + await up(db); + + const skill = await sql<{ name: string; manifest: unknown }>` + SELECT name, json(manifest) AS manifest FROM skill + `.execute(db); + expect(skill.rows).toHaveLength(1); + expect(skill.rows[0]?.name).toMatch(/^my-skill-[0-9a-f]{4}$/); + expect(skill.rows[0]?.manifest).toMatchObject({ + type: 'git', + name: skill.rows[0]?.name, + url: 'https://example.com/s.git', + }); + expect(Object.keys(skill.rows[0]?.manifest ?? {})).not.toContain('0'); + + const mcp = await sql<{ name: string; manifest: unknown }>` + SELECT name, json(manifest) AS manifest FROM mcp_server + `.execute(db); + expect(mcp.rows[0]?.name).toMatch(/^my-mcp-[0-9a-f]{4}$/); + expect(mcp.rows[0]?.manifest).toMatchObject({ + type: 'http', + name: mcp.rows[0]?.name, + url: 'https://example.com/mcp', + }); + + const provider = await sql<{ name: string; manifest: unknown }>` + SELECT name, json(manifest) AS manifest FROM model_provider + `.execute(db); + expect(provider.rows[0]?.name).toMatch(/^my-prov-[0-9a-f]{4}$/); + expect(provider.rows[0]?.manifest).toMatchObject({ + type: 'custom', + name: provider.rows[0]?.name, + models: [{ model_id: 'upstream' }], + }); + const providerManifest = provider.rows[0]?.manifest; + expect(providerManifest !== null && typeof providerManifest === 'object').toBe(true); + if (providerManifest !== null && typeof providerManifest === 'object' && 'models' in providerManifest) { + const models = providerManifest.models; + expect(Array.isArray(models)).toBe(true); + if (Array.isArray(models) && models[0] !== null && typeof models[0] === 'object' && 'name' in models[0]) { + expect(models[0].name).toMatch(/^my-model-[0-9a-f]{4}$/); + } + } + + const agent = await sql<{ name: string; manifest: unknown }>` + SELECT name, json(manifest) AS manifest FROM agent + `.execute(db); + expect(agent.rows[0]?.name).toMatch(/^my-agent-[0-9a-f]{4}$/); + const agentManifest = agent.rows[0]?.manifest; + expect(agentManifest).toMatchObject({ + mcp_servers: [{ name: mcp.rows[0]?.name }], + skills: [{ name: skill.rows[0]?.name }], + }); + expect( + agentManifest !== null && + typeof agentManifest === 'object' && + 'model' in agentManifest && + agentManifest.model !== null && + typeof agentManifest.model === 'object' && + 'name' in agentManifest.model && + typeof agentManifest.model.name === 'string' && + agentManifest.model.name.startsWith(`${provider.rows[0]?.name}/`) && + /\/my-model-[0-9a-f]{4}$/.test(agentManifest.model.name), + ).toBe(true); + + const session = await sql<{ agent_name: string; agent_spec: unknown }>` + SELECT agent_name, json(agent_spec) AS agent_spec FROM session + `.execute(db); + expect(session.rows[0]?.agent_name).toBe(agent.rows[0]?.name); + expect(session.rows[0]?.agent_spec).toEqual(agent.rows[0]?.manifest); + } finally { + await db.destroy(); + } + }); +}); diff --git a/packages/trueforge/tests/unit/schemas/nameSchema.test.ts b/packages/trueforge/tests/unit/schemas/nameSchema.test.ts new file mode 100644 index 000000000..46175cb5b --- /dev/null +++ b/packages/trueforge/tests/unit/schemas/nameSchema.test.ts @@ -0,0 +1,19 @@ +import { NameSchema } from '../../../src/schemas/common'; + +describe('NameSchema', () => { + it('accepts hyphen-only names of length 2–64', () => { + expect(NameSchema.parse('ab')).toBe('ab'); + expect(NameSchema.parse('a-b')).toBe('a-b'); + expect(NameSchema.parse(`a${'b'.repeat(63)}`)).toHaveLength(64); + expect(NameSchema.parse(`a${'-'.repeat(62)}b`)).toHaveLength(64); + }); + + it('rejects ".", "_", leading/trailing hyphen, and out-of-range length', () => { + expect(NameSchema.safeParse('a.b').success).toBe(false); + expect(NameSchema.safeParse('a_b').success).toBe(false); + expect(NameSchema.safeParse('-ab').success).toBe(false); + expect(NameSchema.safeParse('ab-').success).toBe(false); + expect(NameSchema.safeParse('a').success).toBe(false); + expect(NameSchema.safeParse(`a${'b'.repeat(64)}`).success).toBe(false); + }); +}); diff --git a/packages/trueforge/tests/unit/truefoundry/trueFoundryNaming.test.ts b/packages/trueforge/tests/unit/truefoundry/trueFoundryNaming.test.ts new file mode 100644 index 000000000..b3896375b --- /dev/null +++ b/packages/trueforge/tests/unit/truefoundry/trueFoundryNaming.test.ts @@ -0,0 +1,319 @@ +/** + * TrueFoundry-mode naming: agent identity uses NameSchema; fetched skills/MCP/models + * and AgentSpec refs may use FQNs / longer SVC names that are not ResourceName. + */ +import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; +import { createLogger } from 'winston'; +import { CreateAgentRequestSchema, UpdateAgentRequestSchema } from '../../../src/schemas/agent'; +import { NameSchema } from '../../../src/schemas/common'; +import { + AvailableMcpServerSchema, + ConfiguredMcpServerSchema, + McpServerManifestSchema, +} from '../../../src/schemas/mcpServer'; +import { AvailableModelSchema } from '../../../src/schemas/modelProvider'; +import { ConfiguredSkillSchema, SkillManifestSchema, SkillVersionSchema } from '../../../src/schemas/skill'; +import { createTrueFoundryRequestContext } from '../../../src/truefoundry/accessToken'; +import { mapEnabledModels } from '../../../src/truefoundry/mapEnabledModels'; +import { mapSfyRegistrySkills } from '../../../src/truefoundry/mapSfyAgentSkills'; +import { MCP_PROXY_BASE_URL_TEMPLATE, toTrueFoundryMcpManifest } from '../../../src/truefoundry/mapSfyMcpServers'; +import { TrueFoundryMcpServerStore } from '../../../src/truefoundry/TrueFoundryMcpServerStore'; +import { TrueFoundrySkillStore } from '../../../src/truefoundry/TrueFoundrySkillStore'; + +const SKILL_FQN = 'agent-skill:acme/team_a/my.echo:3'; +const LONG_MCP_NAME = `a${'b'.repeat(71)}`; // 72 chars — within SVC 3–73, outside NameSchema max 64 +const MODEL_FQN = 'openai/gpt-5.2'; +const PROVIDER_ACCOUNT = 'my.custom_provider'; +const MODEL_NAME = 'model.with.dots'; + +const requestContext = createTrueFoundryRequestContext({ + tenant_id: 'default', + subject: { id: 'user-1', type: 'user', display_name: 'user-1' }, + roles: [], + user_credential: 'token', +}); + +describe('TrueFoundry naming vs NameSchema', () => { + it('treats skill FQNs, long MCP names, and model FQNs as non-NameSchema', () => { + expect(NameSchema.safeParse(SKILL_FQN).success).toBe(false); + expect(NameSchema.safeParse(LONG_MCP_NAME).success).toBe(false); + expect(NameSchema.safeParse(MODEL_FQN).success).toBe(false); + expect(LONG_MCP_NAME).toHaveLength(72); + }); + + it('saves and updates agent manifests that reference TFY skill FQN, long MCP name, and model FQN', () => { + const body = CreateAgentRequestSchema.parse({ + name: 'support-bot', + manifest: { + model: { name: MODEL_FQN }, + mcp_servers: [{ name: LONG_MCP_NAME }], + skills: [{ name: SKILL_FQN, preload: false }], + }, + }); + expect(body.name).toBe('support-bot'); + expect(NameSchema.safeParse(body.name).success).toBe(true); + expect(body.manifest.model.name).toBe(MODEL_FQN); + expect(body.manifest.mcp_servers?.[0]?.name).toBe(LONG_MCP_NAME); + expect(body.manifest.skills?.[0]?.name).toBe(SKILL_FQN); + + expect( + CreateAgentRequestSchema.safeParse({ + name: SKILL_FQN, + manifest: { model: { name: MODEL_FQN } }, + }).success, + ).toBe(false); + expect( + CreateAgentRequestSchema.safeParse({ + name: 'my.agent', + manifest: { model: { name: MODEL_FQN } }, + }).success, + ).toBe(false); + + const updated = UpdateAgentRequestSchema.parse({ + manifest: { + model: { name: 'custom.provider/my_model.v1' }, + mcp_servers: [{ name: LONG_MCP_NAME }], + skills: [{ name: SKILL_FQN, preload: true }], + }, + }); + expect(updated.manifest.model.name).toBe('custom.provider/my_model.v1'); + expect(updated.manifest.skills?.[0]?.name).toBe(SKILL_FQN); + }); + + it('AgentSpecSchema alone accepts the same non-NameSchema refs', () => { + const spec = AgentSpecSchema.parse({ + model: { name: 'custom.provider/my_model.v1' }, + mcp_servers: [{ name: LONG_MCP_NAME }, { name: 'svc.mcp_with.dots' }], + skills: [{ name: SKILL_FQN }, { name: 'agent-skill:org/repo.with.dots/skill_name:12' }], + }); + expect(spec.model.name).toBe('custom.provider/my_model.v1'); + expect(spec.mcp_servers?.map(s => s.name)).toEqual([LONG_MCP_NAME, 'svc.mcp_with.dots']); + expect(spec.skills?.map(s => s.name)).toEqual([SKILL_FQN, 'agent-skill:org/repo.with.dots/skill_name:12']); + }); + + it('skill list/version wire schemas accept registry FQNs (not NameSchema)', () => { + expect( + SkillManifestSchema.parse({ + type: 'truefoundry', + name: SKILL_FQN, + display_name: 'my.echo', + description: 'Echo from registry.', + repository_name: 'team_a', + version: 3, + }).name, + ).toBe(SKILL_FQN); + + expect( + ConfiguredSkillSchema.parse({ + name: SKILL_FQN, + manifest: { + type: 'truefoundry', + name: SKILL_FQN, + display_name: 'my.echo', + description: 'Echo from registry.', + repository_name: 'team_a', + version: 3, + }, + }).name, + ).toBe(SKILL_FQN); + + expect( + SkillVersionSchema.parse({ + name: SKILL_FQN, + display_name: 'my.echo', + description: 'v3', + version: 3, + }).name, + ).toBe(SKILL_FQN); + + expect( + mapSfyRegistrySkills([ + { + id: 'skill-1', + fqn: 'agent-skill:acme/team_a/my.echo', + name: 'my.echo', + latest_version: { + id: 'ver-1', + fqn: SKILL_FQN, + manifest: { + name: 'my.echo', + type: 'agent-skill', + version: 3, + ml_repo: 'team_a', + source: { type: 'blob-storage', description: 'Echo' }, + }, + }, + }, + ])[0]?.name, + ).toBe(SKILL_FQN); + }); + + it('lists and validates skills by registry FQN without NameSchema', async () => { + const client = { + listAgentSkills: jest.fn().mockResolvedValue([ + { + id: 'skill-1', + fqn: 'agent-skill:acme/team_a/my.echo', + name: 'my.echo', + latest_version: { + id: 'ver-1', + fqn: SKILL_FQN, + manifest: { + name: 'my.echo', + type: 'agent-skill', + version: 3, + ml_repo: 'team_a', + source: { type: 'blob-storage', description: 'Echo' }, + }, + }, + }, + ]), + listAgentSkillVersions: jest.fn(), + resolveAgentSkillVersions: jest + .fn() + .mockResolvedValue([{ fqn: SKILL_FQN, name: 'my.echo', description: 'Echo' }]), + apiKey: 'tfy-api-key', + }; + const store = new TrueFoundrySkillStore({ client, context: requestContext }); + + const listed = await store.listSkills({ tenant_id: 'default', names: undefined }); + expect(listed).toHaveLength(1); + expect(listed[0]?.name).toBe(SKILL_FQN); + expect(NameSchema.safeParse(listed[0]?.name).success).toBe(false); + + await expect( + store.validateAgentSkills({ + tenant_id: 'default', + skills: [{ name: SKILL_FQN, preload: false }], + }), + ).resolves.toBeUndefined(); + expect(client.resolveAgentSkillVersions).toHaveBeenCalledWith({ + accessToken: 'token', + skills: [{ fqn: SKILL_FQN }], + }); + }); + + it('maps SVC MCP rows with long / dotted names without NameSchema.parse', () => { + const dotted = 'org.team_mcp.server'; + expect(NameSchema.safeParse(dotted).success).toBe(false); + + const longManifest = toTrueFoundryMcpManifest({ + server: { + id: 'mcp-long', + name: LONG_MCP_NAME, + proxyUrl: `${MCP_PROXY_BASE_URL_TEMPLATE}/mcp-server/${LONG_MCP_NAME}`, + description: 'Long SVC name', + authType: 'oauth2', + createdAt: '2026-01-15T12:00:00.000Z', + updatedAt: '2026-01-16T12:00:00.000Z', + }, + gatewayUrl: 'https://gateway.example', + }); + expect(longManifest.name).toBe(LONG_MCP_NAME); + expect(longManifest.name).toHaveLength(72); + // Documented OpenAPI type is stricter than runtime TFY mapping. + expect(McpServerManifestSchema.safeParse(longManifest).success).toBe(false); + expect( + AvailableMcpServerSchema.safeParse({ + name: LONG_MCP_NAME, + url: longManifest.url, + auth_status: { status: 'not_required' }, + }).success, + ).toBe(false); + expect( + ConfiguredMcpServerSchema.safeParse({ + name: LONG_MCP_NAME, + manifest: longManifest, + auth_status: { status: 'not_required' }, + }).success, + ).toBe(false); + + const dottedManifest = toTrueFoundryMcpManifest({ + server: { + id: 'mcp-dotted', + name: dotted, + proxyUrl: `${MCP_PROXY_BASE_URL_TEMPLATE}/mcp-server/${dotted}`, + description: 'Dotted SVC name', + authType: undefined, + createdAt: '2026-01-15T12:00:00.000Z', + updatedAt: '2026-01-16T12:00:00.000Z', + }, + gatewayUrl: 'https://gateway.example', + }); + expect(dottedManifest.name).toBe(dotted); + }); + + it('lists long-named MCP servers from SFY without throwing', async () => { + const row = { + id: 'mcp-long', + name: LONG_MCP_NAME, + proxyUrl: `${MCP_PROXY_BASE_URL_TEMPLATE}/mcp-server/${LONG_MCP_NAME}`, + createdAt: '2026-01-15T12:00:00.000Z', + updatedAt: '2026-01-16T12:00:00.000Z', + manifest: { description: 'Long', auth_data: { type: 'oauth2' } }, + }; + const client = { + getMcpServerByName: jest.fn().mockResolvedValue(row), + listMcpServers: jest.fn().mockResolvedValue([row]), + listGatewayInstallations: jest + .fn() + .mockResolvedValue([{ isDefault: true, manifest: { url: 'https://gateway.example' } }]), + getMcpAuthorize: jest.fn(), + getMcpAuthStatus: jest.fn().mockResolvedValue({ status: 'authenticated' }), + deleteMcpAuth: jest.fn(), + vendToken: jest.fn(), + }; + const store = new TrueFoundryMcpServerStore({ + client, + requestContext, + agent: undefined, + logger: createLogger({ silent: true }), + }); + + const listed = await store.listServers({ tenant_id: 'default', names: undefined }); + expect(listed).toHaveLength(1); + expect(listed[0]?.name).toBe(LONG_MCP_NAME); + expect(listed[0]?.manifest.name).toBe(LONG_MCP_NAME); + + const got = await store.getServer({ tenant_id: 'default', name: LONG_MCP_NAME }); + expect(got?.name).toBe(LONG_MCP_NAME); + }); + + it('available model wire schema accepts provider/model FQNs outside NameSchema', () => { + expect( + AvailableModelSchema.parse({ + name: MODEL_FQN, + model_id: 'gpt-5.2', + provider: { name: 'openai' }, + properties: {}, + }).name, + ).toBe(MODEL_FQN); + expect( + AvailableModelSchema.parse({ + name: `${PROVIDER_ACCOUNT}/${MODEL_NAME}`, + model_id: 'upstream/id.with.dots', + provider: { name: PROVIDER_ACCOUNT }, + properties: {}, + }).provider.name, + ).toBe(PROVIDER_ACCOUNT); + }); + + it('maps SFY model integrations with dotted provider/model names without NameSchema', () => { + const models = mapEnabledModels({ + integrations: [ + { + name: MODEL_NAME, + manifest: { model_types: ['chat'] }, + providerAccount: { name: PROVIDER_ACCOUNT }, + metadata: {}, + }, + ], + }); + expect(models).toEqual([{ accountName: PROVIDER_ACCOUNT, modelName: MODEL_NAME, properties: {} }]); + const fqn = `${models[0]?.accountName}/${models[0]?.modelName}`; + expect(fqn).toBe(`${PROVIDER_ACCOUNT}/${MODEL_NAME}`); + expect(NameSchema.safeParse(fqn).success).toBe(false); + expect(NameSchema.safeParse(PROVIDER_ACCOUNT).success).toBe(false); + expect(NameSchema.safeParse(MODEL_NAME).success).toBe(false); + }); +}); From 2d7604ce07faaf728d0980e4f4f83e8ac5d9c0ed Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Fri, 11 Sep 2026 10:31:45 +0000 Subject: [PATCH 2/5] Regenerate OpenAPI document and TypeScript SDK --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 9b076745f..d8d1228ff 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -3383,7 +3383,7 @@ "ResourceName": { "maxLength": 64, "minLength": 2, - "pattern": "^[a-z](?:[a-z0-9._-]{0,62}[a-z0-9])$", + "pattern": "^[a-z][a-z0-9-]{0,62}[a-z0-9]$", "type": "string" }, "ResourcePermission": { diff --git a/docs/openapi.json b/docs/openapi.json index 9b076745f..d8d1228ff 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3383,7 +3383,7 @@ "ResourceName": { "maxLength": 64, "minLength": 2, - "pattern": "^[a-z](?:[a-z0-9._-]{0,62}[a-z0-9])$", + "pattern": "^[a-z][a-z0-9-]{0,62}[a-z0-9]$", "type": "string" }, "ResourcePermission": { From 3c1eed4cfacb97d72dbd7c9e3f6ceaa3a8e0f9fb Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Fri, 11 Sep 2026 18:40:03 +0530 Subject: [PATCH 3/5] fixes --- packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts | 8 +++++--- .../tests/unit/truefoundry/trueFoundryNaming.test.ts | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts b/packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts index 55ca7a4a0..2da7fe332 100644 --- a/packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts +++ b/packages/trueforge/src/db/applyNameHyphenOnlyMigration.ts @@ -279,9 +279,6 @@ export async function applyNameHyphenOnlyMigration({ for (const row of providerRows.rows) { const providerTo = planHyphenOnlyName(row.name) ?? row.name; - if (providerTo !== row.name) { - providerRenames.push({ tenant_id: row.tenant_id, from: row.name, to: providerTo }); - } const manifest = asRecord(row.manifest); if (manifest === undefined) { @@ -327,6 +324,11 @@ export async function applyNameHyphenOnlyMigration({ continue; } + // Only record renames for rows we actually UPDATE (keeps AgentSpec maps in sync). + if (providerTo !== row.name) { + providerRenames.push({ tenant_id: row.tenant_id, from: row.name, to: providerTo }); + } + await sql` UPDATE model_provider SET diff --git a/packages/trueforge/tests/unit/truefoundry/trueFoundryNaming.test.ts b/packages/trueforge/tests/unit/truefoundry/trueFoundryNaming.test.ts index b3896375b..141f5506a 100644 --- a/packages/trueforge/tests/unit/truefoundry/trueFoundryNaming.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/trueFoundryNaming.test.ts @@ -44,6 +44,7 @@ describe('TrueFoundry naming vs NameSchema', () => { it('saves and updates agent manifests that reference TFY skill FQN, long MCP name, and model FQN', () => { const body = CreateAgentRequestSchema.parse({ name: 'support-bot', + description: 'Support agent.', manifest: { model: { name: MODEL_FQN }, mcp_servers: [{ name: LONG_MCP_NAME }], @@ -59,12 +60,14 @@ describe('TrueFoundry naming vs NameSchema', () => { expect( CreateAgentRequestSchema.safeParse({ name: SKILL_FQN, + description: 'x', manifest: { model: { name: MODEL_FQN } }, }).success, ).toBe(false); expect( CreateAgentRequestSchema.safeParse({ name: 'my.agent', + description: 'x', manifest: { model: { name: MODEL_FQN } }, }).success, ).toBe(false); From 9524e34361fae0e56944682eba12462e38bc0f14 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Sat, 12 Sep 2026 11:00:44 +0000 Subject: [PATCH 4/5] Regenerate OpenAPI document and TypeScript SDK --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 8be863ae2..5a2962250 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -5653,7 +5653,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0-rc.6" + "version": "0.2.0-rc.7" }, "openapi": "3.1.0", "paths": { diff --git a/docs/openapi.json b/docs/openapi.json index 8be863ae2..5a2962250 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5653,7 +5653,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0-rc.6" + "version": "0.2.0-rc.7" }, "openapi": "3.1.0", "paths": { From 2c0c8d117d77a2dd4e69cf45735de3a059d31a03 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Sat, 12 Sep 2026 15:54:18 +0000 Subject: [PATCH 5/5] Regenerate OpenAPI document and TypeScript SDK --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 24d5ed227..edbdeb74a 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -5646,7 +5646,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0-rc.7" + "version": "0.2.0-rc.8" }, "openapi": "3.1.0", "paths": { diff --git a/docs/openapi.json b/docs/openapi.json index 24d5ed227..edbdeb74a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5646,7 +5646,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0-rc.7" + "version": "0.2.0-rc.8" }, "openapi": "3.1.0", "paths": {