Files
open-design/apps/daemon/tests/skills-workspace-scope.test.ts
c8f6db60c0 perf(od-next): streamline tool work and reinforce protocol output (#7804)
* perf(od-next): streamline tool use and media input prompts

* fix(od-next): drop the runtime-state literal from the core tool rule

The v2.1.1 core prompt named `open-design-runtime-state` inside the
"not a tool name" bullet. In the two post-optimization eval batches the
model called that exact name as a tool in 5 of 29 deliveries (1 of 30
before the sentence existed): a negated mention raises the name's
salience right where the model is about to emit the runtime-state block.

Keep the rule, drop the literal: protocol blocks are structured text
output, not tools. Bump core asset to 2.1.2 and the bundled package to
2.0.2; orchestration is untouched and stays 2.0.1.

* fix(od-next): carry protocol output guidance into tool optimization

* fix(od-next): preserve run workspace scope in dynamic skill reads

---------

Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
2026-09-08 11:48:16 +00:00

619 lines
26 KiB
TypeScript

// Skill's workspace-isolation onboarding (specs/current/
// 04-resource-workspace-isolation.md §9.1): skill previously had NO
// persistent attribution at all — no table, no metadata.json, nothing —
// which meant `GET /api/skills` returned one flat list to every workspace
// and `DELETE /api/skills/:id` let ANY caller delete ANY skill regardless of
// who imported it or whether it was a team share. Skill now binds into the
// generic `workspace_resources` table (see db.ts) exactly like plugin does:
//
// - `listSkills`'s workspace filter (skills.ts's `skillVisibleFromWorkspace`)
// — mirrors `listInstalledPlugins`'s one-way "unclaimed visible
// everywhere, claimed elsewhere hidden" rule.
// - `DELETE /api/skills/:id` is gated by the shared
// `enforceWorkspaceResourceMutation` (collab/workspace-resource-mutation.ts),
// the same gate `POST /api/plugins/:id/uninstall` uses — see
// tests/plugins-uninstall-workspace-gate.test.ts for the plugin
// equivalent this file mirrors.
//
// Follows the same "seed the skill folder directly on disk, alongside the
// real running server, then bind it via db.ts" pattern as the plugin test:
// db.ts caches one SQLite instance per resolved data dir, and
// RUNTIME_DATA_DIR / OD_DATA_DIR agree within one vitest file, so this
// reuses the server's own connection instead of racing a second one.
import type http from 'node:http';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createOpenDesignToolEnv, startServer } from '../src/server.js';
import {
ensureWorkspaceResource,
getWorkspaceResourceByResourceId,
openDatabase,
updateWorkspaceResource,
} from '../src/db.js';
import { materializeWorkspaceScopedTeamResource } from '../src/collab/team-resource-materialization.js';
import { workspaceTeamSkillBindingResourceId } from '../src/skills/workspace-team-binding.js';
let server: http.Server;
let baseUrl: string;
let shutdown: (() => Promise<void> | void) | undefined;
let userSkillsDir: string;
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
baseUrl = started.url;
server = started.server;
shutdown = started.shutdown;
// Same data root the running server resolved RUNTIME_DATA_DIR from (see
// server.ts's `USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'skills')`);
// tests/setup.ts pins OD_DATA_DIR to an isolated temp dir before any test
// imports server.ts, and it is already absolute, so resolveDataDir()
// returns it unchanged.
userSkillsDir = path.join(process.env.OD_DATA_DIR!, 'skills');
});
afterAll(async () => {
await Promise.resolve(shutdown?.());
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function workspaceHeaders(memberId: string, role: 'owner' | 'admin' | 'member', workspaceId: string) {
return {
'x-od-workspace-id': workspaceId,
'x-od-workspace-member-id': memberId,
'x-od-workspace-role': role,
};
}
async function seedSkillFolder(skillId: string): Promise<string> {
const folder = path.join(userSkillsDir, skillId);
await mkdir(folder, { recursive: true });
await writeFile(
path.join(folder, 'SKILL.md'),
`---\nname: "${skillId}"\ndescription: "Test skill ${skillId}."\n---\n\nBody for ${skillId}.\n`,
);
return folder;
}
function bindSkillToWorkspace(skillId: string, workspaceId: string, createdByWorkspaceMemberId: string) {
const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! });
return ensureWorkspaceResource(db, 'skill', workspaceId, skillId, {
visibility: 'personal',
resourceState: 'active',
createdByWorkspaceMemberId,
updatedByWorkspaceMemberId: createdByWorkspaceMemberId,
});
}
async function fetchSkills(
workspaceId?: string,
workspaceMemberId = 'member-owner',
): Promise<Array<{ id: string; source?: string; teamSynced?: boolean }>> {
const resp = await fetch(
`${baseUrl}/api/skills`,
workspaceId
? {
headers: {
'x-od-workspace-id': workspaceId,
'x-od-workspace-member-id': workspaceMemberId,
},
}
: undefined,
);
const body = (await resp.json()) as {
skills: Array<{ id: string; source?: string; teamSynced?: boolean }>;
};
return body.skills;
}
async function fetchSkillIds(
workspaceId?: string,
workspaceMemberId = 'member-owner',
): Promise<string[]> {
return (await fetchSkills(workspaceId, workspaceMemberId)).map((skill) => skill.id);
}
describe('GET /api/skills — workspace visibility scope', () => {
it.skipIf(process.platform === 'win32')('loads the owner-visible media Skill through the POSIX prompt wrapper and keeps built-in fallback for other callers', async () => {
const skillId = 'od-next-media-inputs';
const workspaceId = 'ws-media-wrapper';
const folder = await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, workspaceId, 'member-owner');
const privateBody = 'PRIVATE MEDIA INPUT INSTRUCTIONS';
await writeFile(path.join(folder, 'SKILL.md'),
`---\nname: "${skillId}"\ndescription: "Private media inputs."\n---\n\n${privateBody}\n`);
const repoRoot = path.resolve(import.meta.dirname, '../../..');
const orchestration = await readFile(path.join(repoRoot,
'plugins/_official/scenarios/od-next-strategy/assets/general-orchestration.md'), 'utf8');
const wrapper = orchestration.split('### Media input Skill')[1]?.match(/\`\`\`sh\n([\s\S]*?)\n\`\`\`/)?.[1];
expect(wrapper).toBeTruthy();
const exec = promisify(execFile);
for (const memberId of ['member-owner', 'member-other', null]) {
const env = {
...process.env,
...createOpenDesignToolEnv({
daemonUrl: baseUrl,
projectId: 'project-media',
workspaceScope: memberId ? {
schemaVersion: 1,
projectId: 'project-media',
workspaceId,
workspaceMemberId: memberId,
source: 'persisted_project_binding',
} : null,
}),
OD_NODE_BIN: process.execPath,
OD_BIN: path.resolve(import.meta.dirname, '../src/cli.ts'),
NODE_OPTIONS: '--import tsx',
};
const { stdout } = await exec('/bin/sh', ['-c', wrapper!], {
cwd: repoRoot, env, timeout: 15_000,
});
const detail = JSON.parse(stdout);
const expectedSource = memberId === 'member-owner' ? 'user' : 'built-in';
expect(detail.source).toBe(expectedSource);
if (memberId === 'member-owner') {
expect(detail.body).toContain(privateBody);
expect(detail.body).not.toContain('# OD Next Media Inputs');
} else {
expect(detail.body).toContain('# OD Next Media Inputs');
expect(detail.body).not.toContain(privateBody);
}
}
});
it('quarantines an unclaimed user skill from every explicit workspace', async () => {
const skillId = `wsscope-unclaimed-${Date.now()}`;
await seedSkillFolder(skillId);
expect(await fetchSkillIds('ws-scope-a', 'member-a')).not.toContain(skillId);
expect(await fetchSkillIds('ws-scope-b', 'member-b')).not.toContain(skillId);
// Signed-out/local catalog remains able to use the preserved legacy skill.
expect(await fetchSkillIds()).toContain(skillId);
});
it('hides a skill claimed by a different workspace, but shows it from its own', async () => {
const skillId = `wsscope-claimed-${Date.now()}`;
await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'ws-scope-owner', 'member-owner');
expect(await fetchSkillIds('ws-scope-owner')).toContain(skillId);
expect(await fetchSkillIds('ws-scope-other')).not.toContain(skillId);
});
it('hides an unshared personal skill from another member in the same workspace', async () => {
const skillId = `wsscope-personal-${Date.now()}`;
await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'ws-scope-team', 'member-owner');
expect(await fetchSkillIds('ws-scope-team', 'member-owner')).toContain(skillId);
expect(await fetchSkillIds('ws-scope-team', 'member-other')).not.toContain(skillId);
const detail = await fetch(`${baseUrl}/api/skills/${skillId}`, {
headers: workspaceHeaders('member-other', 'admin', 'ws-scope-team'),
});
const files = await fetch(`${baseUrl}/api/skills/${skillId}/files`, {
headers: workspaceHeaders('member-other', 'owner', 'ws-scope-team'),
});
expect(detail.status).toBe(404);
expect(files.status).toBe(404);
});
it('shows a shared Team skill to another member in the same workspace', async () => {
const skillId = `wsscope-team-${Date.now()}`;
await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'ws-scope-team-shared', 'member-owner');
const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! });
updateWorkspaceResource(db, 'skill', 'ws-scope-team-shared', skillId, {
visibility: 'team',
});
expect(await fetchSkillIds('ws-scope-team-shared', 'member-other')).toContain(skillId);
const detail = await fetch(`${baseUrl}/api/skills/${skillId}`, {
headers: workspaceHeaders('member-other', 'member', 'ws-scope-team-shared'),
});
expect(detail.status).toBe(200);
});
it('falls back to the bundled skill when another member owns a same-id user shadow', async () => {
const skillId = 'agent-browser';
await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'ws-scope-shadow', 'member-owner');
const mine = (await fetchSkills('ws-scope-shadow', 'member-owner'))
.find((skill) => skill.id === skillId);
const other = (await fetchSkills('ws-scope-shadow', 'member-other'))
.find((skill) => skill.id === skillId);
expect(mine?.source).toBe('user');
expect(other?.source).toBe('built-in');
});
it('keeps a built-in edit shadow private and rejects a same-id overwrite by another member', async () => {
const skillId = 'full-page-screenshot';
const ownerHeaders = {
...workspaceHeaders('member-owner', 'member', 'ws-scope-edit-shadow'),
'content-type': 'application/json',
};
const otherHeaders = {
...workspaceHeaders('member-other', 'admin', 'ws-scope-edit-shadow'),
'content-type': 'application/json',
};
const ownerEdit = await fetch(`${baseUrl}/api/skills/${skillId}`, {
method: 'PUT',
headers: ownerHeaders,
body: JSON.stringify({
name: skillId,
description: 'Private owner edit',
body: 'PRIVATE OWNER SKILL BODY',
}),
});
expect(ownerEdit.status).toBe(200);
const ownerDetail = await fetch(`${baseUrl}/api/skills/${skillId}`, {
headers: ownerHeaders,
});
const otherDetail = await fetch(`${baseUrl}/api/skills/${skillId}`, {
headers: otherHeaders,
});
const ownerBody = await ownerDetail.text();
const otherBody = await otherDetail.text();
expect(ownerBody).toContain('PRIVATE OWNER SKILL BODY');
expect(otherBody).not.toContain('PRIVATE OWNER SKILL BODY');
expect(JSON.parse(otherBody).source).toBe('built-in');
const overwrite = await fetch(`${baseUrl}/api/skills/${skillId}`, {
method: 'PUT',
headers: otherHeaders,
body: JSON.stringify({
name: skillId,
description: 'Attacker edit',
body: 'ATTACKER OVERWRITE',
}),
});
expect(overwrite.status).toBe(409);
const ownerAfter = await fetch(`${baseUrl}/api/skills/${skillId}`, {
headers: ownerHeaders,
});
expect(await ownerAfter.text()).toContain('PRIVATE OWNER SKILL BODY');
});
// spec 04 §10: the symmetric case plugin/design-system already pin — a
// CLAIMED skill must not leak to a caller with no workspace identity at
// all (signed-out client, headerless `curl`), not just to a caller scoped
// to a DIFFERENT workspace. Before this fix, `GET /api/skills` with no
// header fell through to the unfiltered "no scope = everything" branch the
// same way plugin/design-system did — "no scope" must not mean "trust
// everything" (recvqbeDjAsejl / recvqbklNGDqYY).
it('hides a claimed skill from a caller with no workspace header at all', async () => {
const skillId = `wsscope-headerless-${Date.now()}`;
await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'ws-scope-headerless', 'member-owner');
expect(await fetchSkillIds()).not.toContain(skillId);
// Still visible from its exact owner identity.
expect(await fetchSkillIds('ws-scope-headerless')).toContain(skillId);
});
});
describe('DELETE /api/skills/:id — workspace ownership gate', () => {
it('rejects a non-owner, non-privileged member of the same workspace', async () => {
const skillId = `wsgate-member-${Date.now()}`;
const folder = await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'skill-gate-1', 'member-owner');
const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, {
method: 'DELETE',
headers: workspaceHeaders('member-other', 'member', 'skill-gate-1'),
});
expect(resp.status).toBe(404);
expect(existsSync(folder)).toBe(true);
});
it('allows the member who imported the skill to delete it', async () => {
const skillId = `wsgate-self-${Date.now()}`;
const folder = await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'skill-gate-2', 'member-owner');
const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, {
method: 'DELETE',
headers: workspaceHeaders('member-owner', 'member', 'skill-gate-2'),
});
expect(resp.status).toBe(200);
expect(existsSync(folder)).toBe(false);
// The binding row is cleaned up too — no orphan left behind for a future
// re-import of the same id to find and silently reuse.
const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! });
expect(getWorkspaceResourceByResourceId(db, 'skill', skillId)).toBeUndefined();
});
it('does not let a workspace admin discover or delete another member\'s Personal skill', async () => {
const skillId = `wsgate-admin-${Date.now()}`;
const folder = await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'skill-gate-3', 'member-owner');
const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, {
method: 'DELETE',
headers: workspaceHeaders('member-admin', 'admin', 'skill-gate-3'),
});
expect(resp.status).toBe(404);
expect(existsSync(folder)).toBe(true);
});
it('quarantines a legacy unbound skill from an explicitly scoped caller', async () => {
const skillId = `wsgate-legacy-${Date.now()}`;
const folder = await seedSkillFolder(skillId);
const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, {
method: 'DELETE',
headers: workspaceHeaders('member-someone-else', 'member', 'skill-gate-4'),
});
expect(resp.status).toBe(404);
expect(existsSync(folder)).toBe(true);
});
it('keeps a legacy unbound skill manageable from the headerless local lane', async () => {
const skillId = `wsgate-legacy-local-${Date.now()}`;
const folder = await seedSkillFolder(skillId);
const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { method: 'DELETE' });
expect(resp.status).toBe(200);
expect(existsSync(folder)).toBe(false);
});
it('rejects a headerless caller against a team-visibility skill', async () => {
const skillId = `wsgate-team-${Date.now()}`;
const folder = await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'skill-gate-5', 'member-owner');
const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! });
updateWorkspaceResource(db, 'skill', 'skill-gate-5', skillId, { visibility: 'team' });
const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { method: 'DELETE' });
expect(resp.status).toBe(404);
expect(existsSync(folder)).toBe(true);
});
// spec 04 §10 fix #3: `enforceWorkspaceResourceMutation`'s null-ctx branch
// used to only refuse a `visibility: 'team'` row, letting a headerless
// caller delete any BOUND-BUT-`personal` skill (`bindSkillToWorkspace`
// above defaults to `visibility: 'personal'`) — a claimed resource is a
// claimed resource regardless of who else it's shared with.
it('rejects a headerless caller against a personal-visibility (but bound) skill too', async () => {
const skillId = `wsgate-personal-headerless-${Date.now()}`;
const folder = await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'skill-gate-6', 'member-owner');
const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { method: 'DELETE' });
expect(resp.status).toBe(404);
expect(existsSync(folder)).toBe(true);
});
});
describe('Team Skill mutation targets', () => {
it.each(['PUT', 'DELETE'] as const)(
'rejects %s without modifying a same-id Personal skill',
async (method) => {
const skillId = `team-same-id-mutation-${method.toLowerCase()}-${Date.now()}`;
const workspaceId = `team-same-id-workspace-${method.toLowerCase()}`;
const personalFolder = await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, workspaceId, 'member-owner');
const personalBefore = await readFile(path.join(personalFolder, 'SKILL.md'), 'utf8');
const materialized = await materializeWorkspaceScopedTeamResource({
kindRoot: userSkillsDir,
storageName: skillId,
identity: {
kind: 'skill',
workspaceId,
resourceId: skillId,
hubResourceId: `skill-${workspaceId}-${skillId}`,
},
pullInto: async (directory) => {
await writeFile(
path.join(directory, 'SKILL.md'),
`---\nname: "${skillId}"\ndescription: Team copy.\n---\n\nTEAM SKILL BODY\n`,
);
},
verifyWorkspaceScope: async () => true,
verifyStillShared: async () => true,
});
expect(materialized.status).toBe('committed');
const teamFolder = materialized.status === 'committed' ? materialized.targetDir : '';
const teamBefore = await readFile(path.join(teamFolder, 'SKILL.md'), 'utf8');
const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! });
ensureWorkspaceResource(
db,
'skill',
workspaceId,
workspaceTeamSkillBindingResourceId(workspaceId, skillId),
{ visibility: 'team', resourceState: 'active' },
);
const response = await fetch(`${baseUrl}/api/skills/${skillId}`, {
method,
headers: {
...workspaceHeaders('member-owner', 'owner', workspaceId),
...(method === 'PUT' ? { 'content-type': 'application/json' } : {}),
},
...(method === 'PUT'
? {
body: JSON.stringify({
name: skillId,
description: 'Attempted overwrite',
body: 'MUTATED PERSONAL BODY',
}),
}
: {}),
});
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'WORKSPACE_RESOURCE_MANAGE_DENIED' },
});
expect(await readFile(path.join(personalFolder, 'SKILL.md'), 'utf8'))
.toBe(personalBefore);
expect(await readFile(path.join(teamFolder, 'SKILL.md'), 'utf8'))
.toBe(teamBefore);
expect(existsSync(personalFolder)).toBe(true);
expect(existsSync(teamFolder)).toBe(true);
},
);
});
describe('POST /api/skills/install — same-id ownership preflight', () => {
it('does not let another member install over an existing Personal skill identity', async () => {
const skillId = `wsgate-install-${Date.now()}`;
const ownerFolder = await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'skill-install-gate', 'member-owner');
const ownerBefore = await readFile(path.join(ownerFolder, 'SKILL.md'), 'utf8');
const sourceRoot = await mkdtemp(path.join(os.tmpdir(), 'od-skill-conflict-'));
const attackerSource = path.join(sourceRoot, 'different-folder-name');
await mkdir(attackerSource);
await writeFile(
path.join(attackerSource, 'SKILL.md'),
`---\nname: "${skillId}"\ndescription: attacker\n---\n\nATTACKER BYTES\n`,
);
try {
const response = await fetch(`${baseUrl}/api/skills/install`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...workspaceHeaders('member-other', 'admin', 'skill-install-gate'),
},
body: JSON.stringify({ source: 'local', path: attackerSource }),
});
expect(response.status).toBe(409);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'WORKSPACE_RESOURCE_ID_CONFLICT' },
});
expect(await readFile(path.join(ownerFolder, 'SKILL.md'), 'utf8')).toBe(ownerBefore);
expect(existsSync(path.join(userSkillsDir, 'different-folder-name'))).toBe(false);
const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! });
expect(getWorkspaceResourceByResourceId(db, 'skill', skillId)).toMatchObject({
workspaceId: 'skill-install-gate',
createdByWorkspaceMemberId: 'member-owner',
});
} finally {
await rm(sourceRoot, { recursive: true, force: true });
}
});
});
// Skill previously had NO field at all distinguishing a skill materialized
// from a TEAMMATE's team share from one the caller authored themselves — both
// read `source: 'user'`. Design-system (`metadata.json`'s `teamSynced`) and
// plugin (`installed_plugins.source`'s `team:plugin:` prefix) already carried
// this; skill was the one kind missing it entirely, so unsharing a skill
// team-side made the puller's stale copy silently reappear as "Personal"
// instead of just dropping out of the Team scope. `teamSynced` on
// `SkillSummary` closes that gap, sourced from the same `workspace_resources`
// binding `syncSharedTeamSkill`'s `markTeamSynced` (server.ts) already writes
// as `visibility: 'team'` — this test only exercises the READ side (the
// `GET /api/skills` projection), not the write path itself.
describe('GET /api/skills — teamSynced projection', () => {
it('reports teamSynced:true for a skill bound with visibility "team"', async () => {
const skillId = `wsteamsynced-team-${Date.now()}`;
await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'ws-teamsynced-1', 'member-owner');
const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! });
updateWorkspaceResource(db, 'skill', 'ws-teamsynced-1', skillId, { visibility: 'team' });
const resp = await fetch(`${baseUrl}/api/skills`, {
headers: workspaceHeaders('member-owner', 'member', 'ws-teamsynced-1'),
});
const body = (await resp.json()) as { skills: Array<{ id: string; teamSynced?: boolean }> };
const skill = body.skills.find((s) => s.id === skillId);
expect(skill?.teamSynced).toBe(true);
});
it('hides a tombstoned Team mirror while retaining its recovery copy and attribution', async () => {
const workspaceId = 'ws-teamsynced-tombstone';
const skillId = `wsteamsynced-tombstone-${Date.now()}`;
const materialized = await materializeWorkspaceScopedTeamResource({
kindRoot: userSkillsDir,
storageName: skillId,
identity: {
kind: 'skill',
workspaceId,
resourceId: skillId,
hubResourceId: `skill-${workspaceId}-${skillId}`,
},
pullInto: (directory) => writeFile(
path.join(directory, 'SKILL.md'),
`---\nname: "${skillId}"\ndescription: "Test Team skill ${skillId}."\n---\n\nBody for ${skillId}.\n`,
),
verifyWorkspaceScope: async () => true,
verifyStillShared: async () => true,
});
expect(materialized.status).toBe('committed');
const folder = materialized.status === 'committed' ? materialized.targetDir : '';
const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! });
const bindingId = workspaceTeamSkillBindingResourceId(workspaceId, skillId);
ensureWorkspaceResource(db, 'skill', workspaceId, bindingId, {
visibility: 'team',
resourceState: 'active',
createdByWorkspaceMemberId: 'member-owner',
});
expect(await fetchSkillIds(workspaceId)).toContain(skillId);
updateWorkspaceResource(db, 'skill', workspaceId, bindingId, { resourceState: 'deleted' });
expect(await fetchSkillIds(workspaceId)).not.toContain(skillId);
expect(existsSync(folder)).toBe(true);
expect(getWorkspaceResourceByResourceId(db, 'skill', bindingId)).toMatchObject({
workspaceId,
visibility: 'team',
resourceState: 'deleted',
});
});
it('omits teamSynced for a personal-visibility bound skill (the sharer\'s own copy)', async () => {
const skillId = `wsteamsynced-personal-${Date.now()}`;
await seedSkillFolder(skillId);
bindSkillToWorkspace(skillId, 'ws-teamsynced-2', 'member-owner');
const resp = await fetch(`${baseUrl}/api/skills`, {
headers: workspaceHeaders('member-owner', 'member', 'ws-teamsynced-2'),
});
const body = (await resp.json()) as { skills: Array<{ id: string; teamSynced?: boolean }> };
const skill = body.skills.find((s) => s.id === skillId);
expect(skill).toBeTruthy();
expect(skill?.teamSynced).toBeFalsy();
});
it('keeps an unbound legacy skill out of an explicit workspace', async () => {
const skillId = `wsteamsynced-legacy-${Date.now()}`;
await seedSkillFolder(skillId);
const resp = await fetch(`${baseUrl}/api/skills`, {
headers: workspaceHeaders('member-owner', 'member', 'ws-teamsynced-legacy'),
});
const body = (await resp.json()) as { skills: Array<{ id: string; teamSynced?: boolean }> };
const skill = body.skills.find((s) => s.id === skillId);
expect(skill).toBeUndefined();
expect((await fetchSkills()).find((row) => row.id === skillId)?.teamSynced).toBeFalsy();
});
});