diff --git a/packages/agent-connector/src/adapters/claude.js b/packages/agent-connector/src/adapters/claude.js index 71e3ac64e..9eec83dcc 100644 --- a/packages/agent-connector/src/adapters/claude.js +++ b/packages/agent-connector/src/adapters/claude.js @@ -18,7 +18,7 @@ const { execSync, spawn } = require('child_process'); const BaseAdapter = require('./base'); const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils'); -const { buildClaudeSystemPrompt, buildClaudeSkillMd } = require('./workspace-prompt'); +const { buildClaudeSystemPrompt, buildClaudeSkillMd, workspaceSkillName } = require('./workspace-prompt'); const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths'); const IS_WINDOWS = process.platform === 'win32'; @@ -454,7 +454,12 @@ class ClaudeAdapter extends BaseAdapter { const workDir = this.workingDir || defaultAgentWorkdir(this.agentName); const skillDir = path.join(workDir, '.claude', 'skills'); fs.mkdirSync(skillDir, { recursive: true }); - const skillFile = path.join(skillDir, 'openagents-workspace.md'); + // Per-agent filename: agents sharing a working directory used to clobber + // one shared openagents-workspace.md, so uploads carried the identity of + // whichever agent wrote the file last. Drop that legacy file so a stale + // copy (with another agent's embedded identity) can never be read again. + const skillFile = path.join(skillDir, `${workspaceSkillName(this.agentName)}.md`); + try { fs.unlinkSync(path.join(skillDir, 'openagents-workspace.md')); } catch {} const skillContent = buildClaudeSkillMd({ endpoint: this.endpoint, diff --git a/packages/agent-connector/src/adapters/cursor.js b/packages/agent-connector/src/adapters/cursor.js index 2763d52b7..be01d595e 100644 --- a/packages/agent-connector/src/adapters/cursor.js +++ b/packages/agent-connector/src/adapters/cursor.js @@ -16,7 +16,7 @@ const { execSync, spawn } = require('child_process'); const BaseAdapter = require('./base'); const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils'); -const { buildCursorSkillMd } = require('./workspace-prompt'); +const { buildCursorSkillMd, workspaceSkillName } = require('./workspace-prompt'); const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths'); const IS_WINDOWS = process.platform === 'win32'; @@ -308,7 +308,12 @@ class CursorAdapter extends BaseAdapter { const workDir = this.workingDir || defaultAgentWorkdir(this.agentName); const skillDir = path.join(workDir, '.cursor', 'skills'); fs.mkdirSync(skillDir, { recursive: true }); - const skillFile = path.join(skillDir, 'openagents-workspace.md'); + // Per-agent filename: agents sharing a working directory used to clobber + // one shared openagents-workspace.md, so uploads carried the identity of + // whichever agent wrote the file last. Drop that legacy file so a stale + // copy (with another agent's embedded identity) can never be read again. + const skillFile = path.join(skillDir, `${workspaceSkillName(this.agentName)}.md`); + try { fs.unlinkSync(path.join(skillDir, 'openagents-workspace.md')); } catch {} const skillContent = buildCursorSkillMd({ endpoint: this.endpoint, @@ -330,7 +335,20 @@ class CursorAdapter extends BaseAdapter { throw new Error('Cursor CLI not found. Install with: curl https://cursor.com/install -fsSL | bash'); } - const cmd = [agentBin, '-p', prompt, '--output-format', 'stream-json', '--trust', '--force']; + // Cursor has no system-prompt flag, so the CLI would otherwise learn its + // identity ONLY from whichever workspace skill it happens to open. With a + // shared working directory several agents' skills coexist (same + // description, different embedded identity), so every spawn must pin who + // this agent is and exactly which skill file is its interface — otherwise + // it can read a sibling's skill and upload under that agent's name. + const identityHeader = + `[workspace] You are agent '${this.agentName}'. For ANY workspace ` + + `operation (messages, files, agents, browser) read and follow ONLY the ` + + `'${workspaceSkillName(this.agentName)}' skill. Other ` + + `openagents-workspace-* skills belong to OTHER agents and carry the ` + + `wrong identity — never use them.\n\n`; + + const cmd = [agentBin, '-p', identityHeader + prompt, '--output-format', 'stream-json', '--trust', '--force']; // Model selection const model = (this.agentEnv || process.env).CURSOR_MODEL; diff --git a/packages/agent-connector/src/adapters/openclaw.js b/packages/agent-connector/src/adapters/openclaw.js index 143aba62b..5d2b014df 100644 --- a/packages/agent-connector/src/adapters/openclaw.js +++ b/packages/agent-connector/src/adapters/openclaw.js @@ -18,7 +18,7 @@ const { spawn, execSync } = require('child_process'); const BaseAdapter = require('./base'); const { formatAttachmentsForPrompt } = require('./utils'); -const { buildOpenclawSkillMd, buildOpenclawSystemPrompt } = require('./workspace-prompt'); +const { buildOpenclawSkillMd, buildOpenclawSystemPrompt, workspaceSkillName } = require('./workspace-prompt'); const { getRuntimePrefix } = require('../paths'); const IS_WINDOWS = process.platform === 'win32'; @@ -151,7 +151,44 @@ class OpenClawAdapter extends BaseAdapter { return; } - const skillName = `openagents-workspace-${this.agentName}`; + const skillName = workspaceSkillName(this.agentName); + // Migration — pre-normalization versions installed the skill under the + // RAW agent name. For names the normalizer rewrites (uppercase, + // underscores, …) that leaves a stale directory with an outdated + // channel/token/endpoint that OpenClaw would keep auto-loading alongside + // the new one (`always: true` metadata). Remove the exact legacy dir when + // its name differs from the current one; for already-valid names the two + // coincide and nothing is deleted. + // The raw name feeds a recursive delete, so it must be provably inert + // first — agent names are not validated at creation, and a name carrying + // path separators or `..` would otherwise let the join escape the skills + // root (rmSync then destroys whatever it lands on). Only names the safe + // charset allows are considered, and the resolved target must still be a + // DIRECT child of the skills root. Legacy dirs from weirder names are + // left in place — stale beats deleting an unproven path. + const skillsRoot = path.join(wsDir, 'skills'); + const legacyName = `openagents-workspace-${this.agentName}`; + const legacyDir = path.join(skillsRoot, legacyName); + if ( + legacyName !== skillName && + /^[A-Za-z0-9._-]+$/.test(String(this.agentName)) && + path.dirname(path.resolve(legacyDir)) === path.resolve(skillsRoot) + ) { + // Ownership proof — the path checks above cannot see filesystem + // aliasing (case-insensitive lookup on Windows/macOS, trailing-dot + // stripping on Windows): agent Foo's legacy path can be the SAME + // directory as agent foo's CURRENT skill. The stored SKILL.md embeds + // its owner's identity in a quote-delimited phrase, so delete only + // when that identity is exactly this agent; otherwise leave the dir. + let owned = false; + try { + const stored = fs.readFileSync(path.join(legacyDir, 'SKILL.md'), 'utf-8'); + owned = stored.includes(`agent '${this.agentName}'`); + } catch {} + if (owned) { + try { fs.rmSync(legacyDir, { recursive: true, force: true }); } catch {} + } + } const skillDir = path.join(wsDir, 'skills', skillName); fs.mkdirSync(skillDir, { recursive: true }); diff --git a/packages/agent-connector/src/adapters/opencode.js b/packages/agent-connector/src/adapters/opencode.js index 7f2516294..80c0698f6 100644 --- a/packages/agent-connector/src/adapters/opencode.js +++ b/packages/agent-connector/src/adapters/opencode.js @@ -17,7 +17,7 @@ const { execSync, spawn } = require('child_process'); const BaseAdapter = require('./base'); const { formatAttachmentsForPrompt } = require('./utils'); -const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt } = require('./workspace-prompt'); +const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt, workspaceSkillName } = require('./workspace-prompt'); const { whichBinary, whereBinary, getEnhancedEnv } = require('../paths'); const IS_WINDOWS = process.platform === 'win32'; @@ -168,7 +168,12 @@ class OpenCodeAdapter extends BaseAdapter { */ _ensureWorkspaceSkill(channelName) { const skillDir = path.join(this.agentHome, '.opencode', 'skills'); - const skillFile = path.join(skillDir, 'openagents-workspace.md'); + // Filename matches the per-agent frontmatter name; remove the legacy + // shared-name file so a stale duplicate skill is never auto-discovered. + const skillFile = path.join(skillDir, `${workspaceSkillName(this.agentName)}.md`); + try { + fs.unlinkSync(path.join(skillDir, 'openagents-workspace.md')); + } catch {} try { const content = buildOpenCodeSkillMd({ endpoint: this.endpoint, diff --git a/packages/agent-connector/src/adapters/workspace-prompt.js b/packages/agent-connector/src/adapters/workspace-prompt.js index a1912e05e..90385e7a8 100644 --- a/packages/agent-connector/src/adapters/workspace-prompt.js +++ b/packages/agent-connector/src/adapters/workspace-prompt.js @@ -11,6 +11,8 @@ 'use strict'; +const crypto = require('crypto'); + /** * Strong directive forcing agents to use the workspace browser when the * workspace has Browser Fabric enabled. Emitted high in the system prompt @@ -56,18 +58,59 @@ function buildBrowserDirective(browserEnabled) { ); } +/** + * Per-agent name for the workspace skill file. + * + * The skill used to be named plain `openagents-workspace` for every agent. + * Two agents configured with the same working directory then wrote the SAME + * skill file, and the `source: openagents:` identity embedded in its + * curl commands belonged to whichever agent wrote last — so files one agent + * uploaded showed up attributed to the other. Namespacing the skill by agent + * (as the OpenClaw adapter already does) keeps each agent reading only its + * own identity even when working directories are shared. + * + * The result must satisfy the Agent Skills naming rules (lowercase kebab-case, + * no consecutive/leading/trailing hyphens, max 64 chars) or the skill may not + * be discovered at all. Agent names are looser than that (uppercase, + * underscores, up to 64 chars), so normalization is lossy — and a lossy name + * gets a stable short hash of the RAW name appended, because two distinct + * agents converging on one normalized name (e.g. `My_Agent` and `my-agent`) + * would silently reintroduce the exact shared-identity bug this prevents. + * + * Known, accepted limitation — the encoding is not closed: a verbatim legal + * name can in principle equal another name's `-` output (an + * agent literally named `my-agent-3aa88293` collides with `My_Agent`). + * Triggering it takes either a ~2^-32 accident or deliberately naming an + * agent after another's hash — and whoever edits daemon.yaml on this machine + * can already read every skill file and token directly, so nothing is gained. + * Hashing EVERY name would close the gap but make all skill names carry a + * hash suffix; readability of common names was chosen instead. + */ +function workspaceSkillName(agentName) { + const PREFIX = 'openagents-workspace-'; + const budget = 64 - PREFIX.length; + const raw = String(agentName || 'agent'); + let safe = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + if (safe !== raw || safe.length > budget) { + const hash = crypto.createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 8); + safe = safe.slice(0, budget - 9).replace(/-+$/, ''); + safe = safe ? `${safe}-${hash}` : hash; + } + return PREFIX + safe; +} + /** * Build the identity section common to all adapters. * * `toolMode` controls how the "read prior context" hint is phrased: * - `'mcp'` → names the native `workspace_get_history` MCP tool. - * - `'skills'` → points at the openagents-workspace skill (Bash + curl), + * - `'skills'` → points at the agent's workspace skill (Bash + curl), * since no MCP server is spawned and that tool would not exist. */ function buildWorkspaceIdentity(agentName, workspaceId, channelName, mode = 'execute', toolMode = 'mcp') { const priorContext = toolMode === 'skills' ? ( - 'When you need prior context, use the openagents-workspace skill to ' + + `When you need prior context, use the ${workspaceSkillName(agentName)} skill to ` + `read this channel's recent messages (with \`channel="${channelName}"\`). ` + 'Always specify the channel — the default may be different from where ' + 'you are.\n' @@ -97,10 +140,10 @@ function buildWorkspaceIdentity(agentName, workspaceId, channelName, mode = 'exe * the skill's "Discover Agents" section (Bash + curl), `mcp` mode names the * native tool. */ -function buildCollaborationPrompt(toolMode = 'mcp') { +function buildCollaborationPrompt(toolMode = 'mcp', skillName = 'openagents-workspace') { const discover = toolMode === 'skills' ? ( - 'Before delegating, use the openagents-workspace skill (see its ' + + `Before delegating, use the ${skillName} skill (see its ` + '"Discover Agents" section) to list the agents in this channel and read ' + 'each one\'s description, then @mention the best-matched agent.\n' ) @@ -541,16 +584,16 @@ function buildClaudeMcpToolBlock() { /** * The skills tool-reference block for Claude in `skills` tool mode. In this * mode there is no MCP server — every workspace operation goes through the - * openagents-workspace skill (Bash + curl), so we must NOT name any + * agent's workspace skill (Bash + curl), so we must NOT name any * `workspace_*` MCP tool here. */ -function buildClaudeSkillsToolBlock() { +function buildClaudeSkillsToolBlock(skillName = 'openagents-workspace') { return ( - 'IMPORTANT: READ the openagents-workspace skill FIRST, before any workspace\n' + + `IMPORTANT: READ the ${skillName} skill FIRST, before any workspace\n` + 'action. It is your ONLY interface to the workspace — every operation goes\n' + 'through the exact Bash + curl commands documented there. Do not guess\n' + 'endpoints or improvise; open and follow the skill instructions.\n' + - 'The openagents-workspace skill (Bash + curl) covers all workspace operations:\n' + + `The ${skillName} skill (Bash + curl) covers all workspace operations:\n` + 'reading message history, discovering agents (and who is in this channel),\n' + 'sharing files, browsing the shared browser, managing to-do lists, setting\n' + 'timers, creating routines, sending inbox notifications, and reading/writing\n' + @@ -570,11 +613,12 @@ function buildClaudeSkillsToolBlock() { * block, which silently leaked stale MCP tool names when the list changed). */ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = 'execute', browserEnabled = false, toolMode = 'mcp' }) { + const skillName = workspaceSkillName(agentName); const parts = []; parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, toolMode)); - parts.push(toolMode === 'skills' ? buildClaudeSkillsToolBlock() : buildClaudeMcpToolBlock()); + parts.push(toolMode === 'skills' ? buildClaudeSkillsToolBlock(skillName) : buildClaudeMcpToolBlock()); parts.push(buildBrowserDirective(browserEnabled)); - parts.push(buildCollaborationPrompt(toolMode)); + parts.push(buildCollaborationPrompt(toolMode, skillName)); if (mode === 'plan') { parts.push( @@ -595,7 +639,7 @@ function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoi const parts = []; parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills')); parts.push(buildBrowserDirective(browserEnabled)); - parts.push(buildCollaborationPrompt('skills')); + parts.push(buildCollaborationPrompt('skills', workspaceSkillName(agentName))); parts.push(buildModePrompt(mode)); parts.push(buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode, @@ -614,11 +658,11 @@ function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channel const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, 'execute', 'skills'); const directive = buildBrowserDirective(browserEnabled); - const collab = buildCollaborationPrompt('skills'); + const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName)); const frontmatter = ( '---\n' + - 'name: openagents-workspace\n' + + `name: ${workspaceSkillName(agentName)}\n` + 'description: "Share files, browse websites, and collaborate ' + 'with other agents in an OpenAgents workspace. Use when: ' + '(1) sharing results or reports with the user or other agents, ' + @@ -639,7 +683,7 @@ function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channel function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false }) { const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills'); const directive = buildBrowserDirective(browserEnabled); - const collab = buildCollaborationPrompt('skills'); + const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName)); const modePrompt = buildModePrompt(mode); const api = buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode }); return identity + directive + '\n' + collab + '\n' + modePrompt + '\n' + api + '\n' + buildGuardrails(); @@ -658,7 +702,7 @@ function buildOpenCodeSkillMd({ endpoint, workspaceId, token, agentName, channel const frontmatter = '---\n' + - 'name: openagents-workspace\n' + + `name: ${workspaceSkillName(agentName)}\n` + 'description: OpenAgents Workspace API — shared files, browser, and agent collaboration\n' + '---\n\n'; @@ -686,11 +730,11 @@ function buildClaudeSkillMd({ endpoint, workspaceId, token, agentName, channelNa const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, 'execute', 'skills'); const directive = buildBrowserDirective(browserEnabled); - const collab = buildCollaborationPrompt('skills'); + const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName)); const frontmatter = '---\n' + - 'name: openagents-workspace\n' + + `name: ${workspaceSkillName(agentName)}\n` + 'description: |\n' + ' OpenAgents Workspace collaboration tools — shared files, browser,\n' + ' and multi-agent coordination. Use when: sharing files or reports,\n' + @@ -704,7 +748,7 @@ function buildClaudeSkillMd({ endpoint, workspaceId, token, agentName, channelNa /** * Build a SKILL.md file for Cursor CLI's skill auto-discovery. * - * Written to .cursor/skills/openagents-workspace.md before each CLI spawn. + * Written to .cursor/skills/.md before each CLI spawn. * Cursor discovers skills from the .cursor/skills/ directory automatically. */ function buildCursorSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules, browserEnabled = false }) { @@ -717,11 +761,11 @@ function buildCursorSkillMd({ endpoint, workspaceId, token, agentName, channelNa const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, 'execute', 'skills'); const directive = buildBrowserDirective(browserEnabled); - const collab = buildCollaborationPrompt('skills'); + const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName)); const frontmatter = '---\n' + - 'name: openagents-workspace\n' + + `name: ${workspaceSkillName(agentName)}\n` + 'description: |\n' + ' OpenAgents Workspace collaboration tools — shared files, browser,\n' + ' and multi-agent coordination. Use when: sharing files or reports,\n' + @@ -733,6 +777,7 @@ function buildCursorSkillMd({ endpoint, workspaceId, token, agentName, channelNa } module.exports = { + workspaceSkillName, buildWorkspaceIdentity, buildBrowserDirective, buildCollaborationPrompt, diff --git a/packages/agent-connector/test/workspace-prompt.test.js b/packages/agent-connector/test/workspace-prompt.test.js new file mode 100644 index 000000000..9d81aac7c --- /dev/null +++ b/packages/agent-connector/test/workspace-prompt.test.js @@ -0,0 +1,312 @@ +'use strict'; + +/** + * Workspace-prompt identity tests. + * + * Guards the per-agent skill naming that fixes cross-agent identity clobbering: + * two agents sharing a working directory used to overwrite one shared + * `openagents-workspace.md`, so the `source: openagents:` embedded in + * its curl commands belonged to whichever agent wrote last — and files one + * agent uploaded were attributed to the other in the workspace Files list. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + workspaceSkillName, + buildClaudeSkillMd, + buildCursorSkillMd, + buildOpenCodeSkillMd, + buildOpenclawSkillMd, + buildClaudeSystemPrompt, +} = require('../src/adapters/workspace-prompt'); + +const COMMON = { + endpoint: 'https://example.test', + workspaceId: 'ws-1', + token: 'tok', + channelName: 'general', + disabledModules: new Set(), +}; + +// Agent Skills spec: lowercase kebab-case, no leading/trailing/consecutive +// hyphens, at most 64 characters (https://agentskills.io/specification). +const SPEC_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +describe('workspaceSkillName', () => { + it('namespaces the skill by agent, keeping already-valid names readable', () => { + assert.strictEqual(workspaceSkillName('qiyue-bot'), 'openagents-workspace-qiyue-bot'); + }); + + it('falls back to a stable default for empty names', () => { + assert.strictEqual(workspaceSkillName(''), 'openagents-workspace-agent'); + assert.strictEqual(workspaceSkillName(undefined), 'openagents-workspace-agent'); + }); + + it('always emits a spec-compliant name, whatever the agent name', () => { + const hostile = [ + 'My_Agent', 'UPPER', 'my agent/№1', '---a__B---', '.dots.everywhere.', + '中文名字', 'a'.repeat(64), `X${'-'.repeat(30)}y`, '🤖', + ]; + for (const name of hostile) { + const skill = workspaceSkillName(name); + assert.ok(skill.length <= 64, `${skill} exceeds 64 chars`); + assert.match(skill, SPEC_NAME_RE, `${skill} is not lowercase kebab-case`); + } + }); + + it('is deterministic', () => { + assert.strictEqual(workspaceSkillName('My_Agent'), workspaceSkillName('My_Agent')); + }); + + it('keeps agents distinct when normalization would make their names collide', () => { + // Case/underscore folding maps both to "my-agent" — converging would + // silently reintroduce the shared-identity clobbering. + assert.notStrictEqual(workspaceSkillName('My_Agent'), workspaceSkillName('my-agent')); + // Truncation of long names must not collide either. + const stem = 'a'.repeat(60); + assert.notStrictEqual(workspaceSkillName(`${stem}x`), workspaceSkillName(`${stem}y`)); + }); +}); + +describe('skill markdown identity', () => { + for (const [label, build] of [ + ['claude', buildClaudeSkillMd], + ['cursor', buildCursorSkillMd], + ['opencode', buildOpenCodeSkillMd], + ['openclaw', buildOpenclawSkillMd], + ]) { + it(`${label}: frontmatter name and curl source both carry the agent identity`, () => { + const md = build({ ...COMMON, agentName: 'qiyue-bot' }); + assert.match(md, /^---\nname: openagents-workspace-qiyue-bot\n/); + assert.ok(md.includes('openagents:qiyue-bot'), 'curl commands must embed the agent identity'); + assert.ok(!md.includes('\nname: openagents-workspace\n'), 'must not use the shared legacy skill name'); + }); + } + + it('two agents produce distinctly named skills (no last-writer-wins)', () => { + const a = buildClaudeSkillMd({ ...COMMON, agentName: 'qiyue-bot' }); + const b = buildClaudeSkillMd({ ...COMMON, agentName: 'cherie-bot' }); + assert.match(a, /name: openagents-workspace-qiyue-bot/); + assert.match(b, /name: openagents-workspace-cherie-bot/); + assert.ok(!a.includes('cherie-bot')); + assert.ok(!b.includes('qiyue-bot')); + }); +}); + +describe('claude system prompt (skills mode)', () => { + it('points the model at its own per-agent skill', () => { + const prompt = buildClaudeSystemPrompt({ + agentName: 'qiyue-bot', + workspaceId: 'ws-1', + channelName: 'general', + toolMode: 'skills', + }); + assert.ok(prompt.includes('openagents-workspace-qiyue-bot skill')); + assert.ok(!/openagents-workspace skill/.test(prompt), 'must not reference the shared legacy skill name'); + }); +}); + +describe('CursorAdapter — shared working directory', () => { + const CursorAdapter = require('../src/adapters/cursor'); + + function makeCursor(agentName, workingDir) { + const adapter = new CursorAdapter({ + workspaceId: 'ws-1', + channelName: 'general', + token: 'tok', + agentName, + workingDir, + }); + adapter._log = () => {}; + return adapter; + } + + it('lets two agents keep coexisting skills, each with its own identity', () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-cursor-test-')); + try { + const skillDir = path.join(workDir, '.cursor', 'skills'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'openagents-workspace.md'), 'stale shared file'); + + makeCursor('qiyue-bot', workDir)._writeSkillFile('general'); + makeCursor('cherie-bot', workDir)._writeSkillFile('general'); + + const qiyue = fs.readFileSync(path.join(skillDir, 'openagents-workspace-qiyue-bot.md'), 'utf-8'); + const cherie = fs.readFileSync(path.join(skillDir, 'openagents-workspace-cherie-bot.md'), 'utf-8'); + assert.ok(qiyue.includes('openagents:qiyue-bot') && !qiyue.includes('cherie-bot')); + assert.ok(cherie.includes('openagents:cherie-bot') && !cherie.includes('qiyue-bot')); + assert.ok(!fs.existsSync(path.join(skillDir, 'openagents-workspace.md')), + 'legacy shared skill file must be deleted'); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + }); + + it('pins identity and the exact skill name in every spawned prompt', () => { + // Cursor has no system-prompt flag: without this header the model only + // learns who it is from whichever coexisting skill it happens to open. + const adapter = makeCursor('qiyue-bot'); + adapter._findCursorBinary = () => '/bin/cursor-agent'; + const cmd = adapter._buildCursorCmd('hello world', 'general'); + const prompt = cmd[cmd.indexOf('-p') + 1]; + assert.ok(prompt.includes("You are agent 'qiyue-bot'")); + assert.ok(prompt.includes("'openagents-workspace-qiyue-bot' skill")); + assert.ok(prompt.endsWith('hello world')); + }); +}); + +describe('OpenClawAdapter — legacy skill dir migration', () => { + const OpenClawAdapter = require('../src/adapters/openclaw'); + + // The base constructor ALREADY runs _installWorkspaceSkill, so the redirect + // to the temp dir must be in place before construction (a prototype-level + // override, not a post-hoc instance patch) — otherwise merely constructing + // the adapter reads/deletes the developer's real ~/.openclaw/workspace. + function makeOpenclaw(agentName, wsDir) { + class SandboxedOpenClaw extends OpenClawAdapter { + _resolveOpenclawWorkspace() { return wsDir; } + } + const origLog = console.log; + console.log = () => {}; + try { + const adapter = new SandboxedOpenClaw({ + workspaceId: 'ws-1', + channelName: 'general', + token: 'tok', + agentName, + }); + adapter._log = () => {}; + return adapter; + } finally { + console.log = origLog; + } + } + + it('removes the raw-name legacy dir when normalization renamed the skill', () => { + const wsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-openclaw-test-')); + try { + // Pre-normalization layout: skill dir named after the RAW agent name, + // whose SKILL.md embeds its owner's identity (as the builder always has). + const legacyDir = path.join(wsDir, 'skills', 'openagents-workspace-My_Agent'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'SKILL.md'), + "You are agent 'My_Agent' connected to an OpenAgents workspace. (stale)"); + + makeOpenclaw('My_Agent', wsDir)._installWorkspaceSkill(); + + const current = path.join(wsDir, 'skills', workspaceSkillName('My_Agent'), 'SKILL.md'); + assert.ok(fs.existsSync(current), 'normalized skill dir must be written'); + assert.ok(fs.readFileSync(current, 'utf-8').includes('openagents:My_Agent')); + assert.ok(!fs.existsSync(legacyDir), 'stale raw-name dir must be deleted'); + } finally { + fs.rmSync(wsDir, { recursive: true, force: true }); + } + }); + + it('never deletes a dir owned by another agent (Windows case-alias regression)', () => { + const wsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-openclaw-test-')); + try { + // On a case-insensitive filesystem, agent Foo's legacy path + // openagents-workspace-Foo IS agent foo's current dir. Materialize that + // alias on any FS — a dir at Foo's legacy path holding foo's skill — + // and require the ownership check to refuse the delete. + const aliasDir = path.join(wsDir, 'skills', 'openagents-workspace-Foo'); + fs.mkdirSync(aliasDir, { recursive: true }); + fs.writeFileSync(path.join(aliasDir, 'SKILL.md'), + "You are agent 'foo' connected to an OpenAgents workspace."); + + makeOpenclaw('Foo', wsDir); + + assert.ok(fs.existsSync(path.join(aliasDir, 'SKILL.md')), + "another agent's live skill must survive Foo's migration"); + } finally { + fs.rmSync(wsDir, { recursive: true, force: true }); + } + }); + + it('skips deletion when ownership cannot be proven', () => { + const wsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-openclaw-test-')); + try { + const legacyDir = path.join(wsDir, 'skills', 'openagents-workspace-My_Agent'); + fs.mkdirSync(legacyDir, { recursive: true }); + // No SKILL.md — nothing proves whose dir this is, so it must be left. + fs.writeFileSync(path.join(legacyDir, 'notes.txt'), 'unrelated'); + + makeOpenclaw('My_Agent', wsDir); + + assert.ok(fs.existsSync(path.join(legacyDir, 'notes.txt'))); + } finally { + fs.rmSync(wsDir, { recursive: true, force: true }); + } + }); + + it('never lets a hostile agent name escape the skills root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-openclaw-test-')); + try { + const wsDir = path.join(root, 'ws'); + const victim = path.join(root, 'victim'); + fs.mkdirSync(path.join(wsDir, 'skills'), { recursive: true }); + fs.mkdirSync(victim, { recursive: true }); + fs.writeFileSync(path.join(victim, 'keep.txt'), 'do not delete'); + + // Joined blindly, the legacy path for this name resolves to /victim. + makeOpenclaw('x/../../../victim', wsDir); + + assert.ok(fs.existsSync(path.join(victim, 'keep.txt')), + 'a path outside the skills root must never be deleted'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('leaves the dir alone when the name needs no normalization', () => { + const wsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-openclaw-test-')); + try { + makeOpenclaw('qiyue-bot', wsDir)._installWorkspaceSkill(); + const skillMd = path.join(wsDir, 'skills', 'openagents-workspace-qiyue-bot', 'SKILL.md'); + assert.ok(fs.existsSync(skillMd)); + assert.ok(fs.readFileSync(skillMd, 'utf-8').includes('openagents:qiyue-bot')); + } finally { + fs.rmSync(wsDir, { recursive: true, force: true }); + } + }); +}); + +describe('ClaudeAdapter._buildSkillsCmd', () => { + it('writes a per-agent skill file and removes the legacy shared one', () => { + const ClaudeAdapter = require('../src/adapters/claude'); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-skill-test-')); + try { + const skillDir = path.join(workDir, '.claude', 'skills'); + fs.mkdirSync(skillDir, { recursive: true }); + // Simulate a stale shared file written by ANOTHER agent (pre-fix layout). + const legacy = path.join(skillDir, 'openagents-workspace.md'); + fs.writeFileSync(legacy, 'source: openagents:cherie-bot'); + + const adapter = new ClaudeAdapter({ + workspaceId: 'ws-1', + channelName: 'general', + token: 'tok', + agentName: 'qiyue-bot', + workingDir: workDir, + toolMode: 'skills', + }); + adapter._log = () => {}; + + const { skillFile } = adapter._buildSkillsCmd(['claude'], 'general'); + + assert.strictEqual(path.basename(skillFile), 'openagents-workspace-qiyue-bot.md'); + const content = fs.readFileSync(skillFile, 'utf-8'); + assert.ok(content.includes('openagents:qiyue-bot')); + assert.ok(!content.includes('cherie-bot')); + assert.ok(!fs.existsSync(legacy), 'legacy shared skill file must be deleted'); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + }); +});