@wipcomputer/wip-ldm-os 0.4.85-alpha.1 → 0.4.85-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ldm.js CHANGED
@@ -738,6 +738,24 @@ function deployDocs() {
738
738
  console.log(` + ${agentDocsCount} personalized doc(s) deployed to ${agentLibraryDest.replace(HOME, '~')}/`);
739
739
  }
740
740
 
741
+ // Migration-window compatibility write (added 2026-04-30).
742
+ // dev-guide-wipcomputerinc.md was migrated from ~/.ldm/shared/ to
743
+ // ~/.ldm/library/documentation/ on 2026-04-19, but agent boot files,
744
+ // ~/.claude/rules/, and other consumers still reference the old shared
745
+ // path. Without this write, the old path serves stale content and
746
+ // agents reading by the old path get pre-migration policy.
747
+ // Forward-migration (grep-update consumers, then remove this compat
748
+ // write) is tracked separately. See bugs/installer/ ticket
749
+ // 2026-04-30--cc-mini--dev-guide-split-path-migration.md.
750
+ const devGuideName = 'dev-guide-wipcomputerinc.md';
751
+ const devGuideNew = join(agentLibraryDest, devGuideName);
752
+ const devGuideOld = join(LDM_ROOT, 'shared', devGuideName);
753
+ if (existsSync(devGuideNew)) {
754
+ mkdirSync(dirname(devGuideOld), { recursive: true });
755
+ cpSync(devGuideNew, devGuideOld);
756
+ console.log(` + Compat write: ${devGuideName} also deployed to ${devGuideOld.replace(HOME, '~')} (migration window)`);
757
+ }
758
+
741
759
  return docsCount + agentDocsCount;
742
760
  }
743
761
 
@@ -3987,7 +4005,7 @@ async function main() {
3987
4005
  console.log(' Module ... ESM main/exports -> importable');
3988
4006
  console.log(' MCP Server ... mcp-server.mjs -> claude mcp add --scope user');
3989
4007
  console.log(' OpenClaw ... openclaw.plugin.json -> ~/.ldm/extensions/ + ~/.openclaw/extensions/');
3990
- console.log(' Skill ... SKILL.md -> ~/.openclaw/skills/<tool>/');
4008
+ console.log(' Skill ... SKILL.md or skills/<name>/SKILL.md -> agent skill paths');
3991
4009
  console.log(' CC Hook ... guard.mjs or claudeCode.hook -> ~/.claude/settings.json');
3992
4010
  console.log('');
3993
4011
  console.log(` v${PKG_VERSION}`);
@@ -144,13 +144,15 @@ A plugin for OpenClaw agents. Lifecycle hooks, tool registration, settings.
144
144
 
145
145
  A markdown file that teaches agents when and how to use the tool. The instruction interface. Follows the [Agent Skills Spec](https://agentskills.io/specification).
146
146
 
147
- **Convention:** `SKILL.md` at the repo root. YAML frontmatter with name, description. Optional `references/` directory for context files.
147
+ **Convention:** either `SKILL.md` at the repo root, or one or more skill folders at `skills/<skill-name>/SKILL.md`. YAML frontmatter includes name and description. Optional `references/`, `agents/`, `scripts/`, and `assets/` directories live beside `SKILL.md`.
148
148
 
149
149
  **Platform variants:** Codex CLI reads `AGENTS.md` instead of `SKILL.md`, with the same role and the same content shape. Treat `AGENTS.md` as the Codex-flavored filename for this same interface, not a separate interface. A repo may ship both (or symlink one to the other) so it works in Codex and SKILL.md-aware agents.
150
150
 
151
- **Detection:** `SKILL.md` exists.
151
+ **Detection:** `SKILL.md` exists, or at least one `skills/<skill-name>/SKILL.md` exists.
152
152
 
153
- **Install:** `SKILL.md` deployed to `~/.openclaw/skills/<name>/`. If `references/` exists, deployed alongside SKILL.md and to `settings/docs/skills/<name>/` in the workspace.
153
+ **Install:** the skill folder is deployed to every supported local agent skill surface, including OpenClaw, Codex, Claude Code, and WIP agent compatibility paths when present. If `references/` exists, it is deployed alongside SKILL.md and to `settings/docs/skills/<name>/` in the workspace.
154
+
155
+ **Npm package shape:** a public skill package can expose `SKILL.md` at package root. For example, `@wipcomputer/wip-ai-chat-ui` is sourced from a private repo folder at `design/skills/wip-ai-chat-ui/`, but publishes a tarball with `SKILL.md`, `agents/`, and `references/` at package root so `ldm install @wipcomputer/wip-ai-chat-ui` installs the skill directly.
154
156
 
155
157
  **Structure:**
156
158
  ```
@@ -162,6 +164,17 @@ repo/
162
164
  └── ...
163
165
  ```
164
166
 
167
+ Multiple skills can also live in one repo:
168
+
169
+ ```text
170
+ repo/
171
+ └── skills/
172
+ └── wip-ai-chat-ui/
173
+ ├── SKILL.md
174
+ ├── agents/
175
+ └── references/
176
+ ```
177
+
165
178
  **Key rules (from Agent Skills Spec):**
166
179
  - SKILL.md body < 5000 tokens. Process goes in SKILL.md, context goes in references/.
167
180
  - Imperative language: "Run this command" not "This product enables..."
@@ -148,13 +148,13 @@ A plugin for OpenClaw agents. Lifecycle hooks, tool registration, settings.
148
148
 
149
149
  A markdown file that teaches agents when and how to use the tool. The instruction interface. Follows the [Agent Skills Spec](https://agentskills.io/specification).
150
150
 
151
- **Convention:** `SKILL.md` at the repo root. Optional `references/` directory for context files.
151
+ **Convention:** either `SKILL.md` at the repo root, or one or more skill folders at `skills/<skill-name>/SKILL.md`. Optional `references/`, `agents/`, `scripts/`, and `assets/` directories live beside `SKILL.md`.
152
152
 
153
153
  **Platform variants:** Codex CLI reads `AGENTS.md` with the same role and content shape. Treat as the Codex-flavored filename for this same interface, not a separate one.
154
154
 
155
- **Detection:** `SKILL.md` exists.
155
+ **Detection:** `SKILL.md` exists, or at least one `skills/<skill-name>/SKILL.md` exists.
156
156
 
157
- **Install:** `ldm install` deploys `SKILL.md` to `~/.openclaw/skills/<name>/`. If `references/` exists, it is deployed alongside and also to `settings/docs/skills/<name>/` in the workspace (so all agents can read them).
157
+ **Install:** `ldm install` deploys the skill folder to every supported local agent skill surface, including OpenClaw, Codex, Claude Code, and WIP agent compatibility paths when present. If `references/` exists, it is deployed alongside and also to `settings/docs/skills/<name>/` in the workspace so all agents can read them.
158
158
 
159
159
  **Key rules:**
160
160
  - SKILL.md body < 5000 tokens. Process in SKILL.md, context in references/.
@@ -364,7 +364,7 @@ ldm install # update all
364
364
  | `mcp-server.mjs` | MCP (local stdio) | Adds `command` + `args` entry to `.mcp.json` |
365
365
  | `mcp.remote.url` in `package.json` | Remote MCP | Adds `url` + `transport` entry to `.mcp.json`; prints Claude Desktop hint. **Implementation in flight ([ticket](../../ai/product/bugs/installer/2026-04-28--cc-mini--installer-remote-mcp-detection.md)).** |
366
366
  | `openclaw.plugin.json` | OpenClaw | Copies to `~/.openclaw/extensions/` |
367
- | `SKILL.md` | Skill | Reports path |
367
+ | `SKILL.md` or `skills/<name>/SKILL.md` | Skill | Deploys skill folder to supported agent skill paths |
368
368
  | `guard.mjs` or `claudeCode.hook` | CC Hook | Adds to `~/.claude/settings.json` |
369
369
  | `.claude-plugin/plugin.json` | CC Plugin | Registers with Claude Code marketplace |
370
370
 
package/lib/deploy.mjs CHANGED
@@ -177,6 +177,14 @@ export function detectHarnesses() {
177
177
  skills: join(codexHome, 'skills'),
178
178
  };
179
179
 
180
+ // WIP agent skill compatibility directory
181
+ const agentsHome = join(HOME, '.agents');
182
+ harnesses['wip-agents'] = {
183
+ detected: existsSync(agentsHome),
184
+ home: agentsHome,
185
+ skills: join(agentsHome, 'skills'),
186
+ };
187
+
180
188
  // Cursor
181
189
  const cursorHome = join(HOME, '.cursor');
182
190
  harnesses['cursor'] = {
@@ -207,7 +215,7 @@ export function detectHarnesses() {
207
215
  function getHarnesses() {
208
216
  try {
209
217
  const config = readJSON(LDM_CONFIG_PATH) || {};
210
- if (config.harnesses) {
218
+ if (config.harnesses && config.harnesses['wip-agents']) {
211
219
  const workspace = (config.workspace || '').replace('~', HOME);
212
220
  return { harnesses: config.harnesses, workspace };
213
221
  }
@@ -1097,18 +1105,18 @@ function registerMCP(repoPath, door, toolName) {
1097
1105
  *
1098
1106
  * Returns true if at least one door installed successfully.
1099
1107
  */
1100
- function installClaudeCodeHook(repoPath, doorOrDoors) {
1108
+ function installClaudeCodeHook(repoPath, doorOrDoors, toolName = basename(repoPath)) {
1101
1109
  const doors = Array.isArray(doorOrDoors) ? doorOrDoors : [doorOrDoors];
1102
1110
  let anyOk = false;
1103
1111
  for (const door of doors) {
1104
- if (installClaudeCodeHookEvent(repoPath, door)) {
1112
+ if (installClaudeCodeHookEvent(repoPath, door, toolName)) {
1105
1113
  anyOk = true;
1106
1114
  }
1107
1115
  }
1108
1116
  return anyOk;
1109
1117
  }
1110
1118
 
1111
- function installClaudeCodeHookEvent(repoPath, door) {
1119
+ function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath)) {
1112
1120
  const settingsPath = join(HOME, '.claude', 'settings.json');
1113
1121
  let settings = readJSON(settingsPath);
1114
1122
 
@@ -1117,7 +1125,6 @@ function installClaudeCodeHookEvent(repoPath, door) {
1117
1125
  return false;
1118
1126
  }
1119
1127
 
1120
- const toolName = basename(repoPath);
1121
1128
  const extDir = join(LDM_EXTENSIONS, toolName);
1122
1129
  const installedGuard = join(extDir, 'guard.mjs');
1123
1130
 
@@ -1237,11 +1244,77 @@ function installClaudeCodeHookEvent(repoPath, door) {
1237
1244
  }
1238
1245
  }
1239
1246
 
1240
- function installSkill(repoPath, toolName) {
1247
+ function copySkillTree(skillDir, dest, copyFullFolder = false) {
1248
+ mkdirSync(dest, { recursive: true });
1249
+ if (copyFullFolder) {
1250
+ cpSync(skillDir, dest, { recursive: true });
1251
+ return;
1252
+ }
1253
+
1254
+ cpSync(join(skillDir, 'SKILL.md'), join(dest, 'SKILL.md'));
1255
+ for (const child of ['references', 'agents', 'scripts', 'assets']) {
1256
+ const src = join(skillDir, child);
1257
+ if (existsSync(src)) cpSync(src, join(dest, child), { recursive: true });
1258
+ }
1259
+ }
1260
+
1261
+ const SKILL_COMPANION_DIRS = ['references', 'agents', 'scripts', 'assets'];
1262
+
1263
+ function listSkillCopyEntries(skillDir, copyFullFolder = false) {
1264
+ if (copyFullFolder) {
1265
+ try {
1266
+ return readdirSync(skillDir)
1267
+ .filter((entry) => entry !== '.DS_Store')
1268
+ .sort();
1269
+ } catch {
1270
+ return ['SKILL.md'];
1271
+ }
1272
+ }
1273
+
1274
+ const entries = ['SKILL.md'];
1275
+ for (const child of SKILL_COMPANION_DIRS) {
1276
+ if (existsSync(join(skillDir, child))) entries.push(`${child}/`);
1277
+ }
1278
+ return entries;
1279
+ }
1280
+
1281
+ function printSkillDryRunPlan({ sourceSkillDir, refsSrc, toolName, harnesses, workspace, entries }) {
1282
+ const formatTargetEntries = (base) => entries.map((entry) => join(base, entry));
1283
+ const harnessTargets = Object.entries(harnesses)
1284
+ .filter(([, h]) => h.detected && h.skills)
1285
+ .map(([name, h]) => ({ name, base: join(h.skills, toolName) }));
1286
+
1287
+ ok(`Skill: ${toolName}`);
1288
+ log(`Source: ${sourceSkillDir}`);
1289
+ log('Would copy:');
1290
+ for (const entry of entries) log(`- ${entry}`);
1291
+
1292
+ const permanentBase = join(LDM_EXTENSIONS, toolName);
1293
+ log('Permanent copy:');
1294
+ for (const target of formatTargetEntries(permanentBase)) log(`- ${target}`);
1295
+
1296
+ if (harnessTargets.length > 0) {
1297
+ log('Agent skill targets:');
1298
+ for (const target of harnessTargets) {
1299
+ log(`- ${target.name}: ${target.base}`);
1300
+ for (const entryTarget of formatTargetEntries(target.base)) log(` - ${entryTarget}`);
1301
+ }
1302
+ } else {
1303
+ log('Agent skill targets: no detected skill harnesses');
1304
+ }
1305
+
1306
+ if (existsSync(refsSrc) && workspace && existsSync(workspace)) {
1307
+ const homeRefsDest = join(workspace, 'settings', 'docs', 'skills', toolName);
1308
+ log('Workspace docs target:');
1309
+ log(`- ${homeRefsDest} (references/ only)`);
1310
+ }
1311
+ }
1312
+
1313
+ function installSkillFolder(skillDir, toolName, opts = {}) {
1241
1314
  const { harnesses, workspace } = getHarnesses();
1242
1315
 
1243
- // Find SKILL.md source: repo path first, then permanent copy at ~/.ldm/extensions/
1244
- let skillSrc = join(repoPath, 'SKILL.md');
1316
+ // Find SKILL.md source: skill dir first, then permanent copy at ~/.ldm/extensions/
1317
+ let skillSrc = join(skillDir, 'SKILL.md');
1245
1318
  const permanentSkill = join(LDM_EXTENSIONS, toolName, 'SKILL.md');
1246
1319
  if (!existsSync(skillSrc) && existsSync(permanentSkill)) skillSrc = permanentSkill;
1247
1320
  if (!existsSync(skillSrc)) return false;
@@ -1252,14 +1325,16 @@ function installSkill(repoPath, toolName) {
1252
1325
  return false;
1253
1326
  }
1254
1327
 
1255
- // Find references/ source: repo path first, then permanent copy
1256
- let refsSrc = join(repoPath, 'references');
1328
+ const sourceSkillDir = dirname(skillSrc);
1329
+
1330
+ // Find references/ source: skill dir first, then permanent copy
1331
+ let refsSrc = join(skillDir, 'references');
1257
1332
  const permanentRefs = join(LDM_EXTENSIONS, toolName, 'references');
1258
1333
  if (!existsSync(refsSrc) && existsSync(permanentRefs)) refsSrc = permanentRefs;
1259
1334
 
1260
1335
  if (DRY_RUN) {
1261
- const targets = Object.entries(harnesses).filter(([,h]) => h.detected && h.skills).map(([name]) => name);
1262
- ok(`Skill: would deploy ${toolName} to ${targets.join(', ')} (dry run)`);
1336
+ const entries = listSkillCopyEntries(sourceSkillDir, opts.copyFullFolder);
1337
+ printSkillDryRunPlan({ sourceSkillDir, refsSrc, toolName, harnesses, workspace, entries });
1263
1338
  return true;
1264
1339
  }
1265
1340
 
@@ -1268,21 +1343,13 @@ function installSkill(repoPath, toolName) {
1268
1343
 
1269
1344
  // 1. Save permanent copy to ~/.ldm/extensions/<name>/ (survives tmp cleanup)
1270
1345
  const ldmSkillDir = join(LDM_EXTENSIONS, toolName);
1271
- mkdirSync(ldmSkillDir, { recursive: true });
1272
- cpSync(skillSrc, join(ldmSkillDir, 'SKILL.md'));
1273
- if (existsSync(refsSrc) && refsSrc !== permanentRefs) {
1274
- cpSync(refsSrc, join(ldmSkillDir, 'references'), { recursive: true });
1275
- }
1346
+ copySkillTree(sourceSkillDir, ldmSkillDir, opts.copyFullFolder);
1276
1347
 
1277
1348
  // 2. Deploy to every detected harness that has a skills path
1278
1349
  for (const [name, harness] of Object.entries(harnesses)) {
1279
1350
  if (!harness.detected || !harness.skills) continue;
1280
1351
  const dest = join(harness.skills, toolName);
1281
- mkdirSync(dest, { recursive: true });
1282
- cpSync(skillSrc, join(dest, 'SKILL.md'));
1283
- if (existsSync(refsSrc)) {
1284
- cpSync(refsSrc, join(dest, 'references'), { recursive: true });
1285
- }
1352
+ copySkillTree(sourceSkillDir, dest, opts.copyFullFolder);
1286
1353
  deployed.push(name);
1287
1354
  }
1288
1355
 
@@ -1302,6 +1369,18 @@ function installSkill(repoPath, toolName) {
1302
1369
  }
1303
1370
  }
1304
1371
 
1372
+ function installSkill(repoPath, toolName, skillInfo = null) {
1373
+ if (Array.isArray(skillInfo?.skills) && skillInfo.skills.length > 0) {
1374
+ let installed = 0;
1375
+ for (const skill of skillInfo.skills) {
1376
+ if (installSkillFolder(skill.path, skill.name, { copyFullFolder: true })) installed++;
1377
+ }
1378
+ return installed > 0;
1379
+ }
1380
+
1381
+ return installSkillFolder(repoPath, toolName);
1382
+ }
1383
+
1305
1384
  // ── Single tool install ──
1306
1385
 
1307
1386
  export function installSingleTool(toolPath) {
@@ -1362,6 +1441,10 @@ export function installSingleTool(toolPath) {
1362
1441
  }
1363
1442
  }
1364
1443
 
1444
+ if (interfaces.skill) {
1445
+ installSkill(toolPath, toolName, interfaces.skill);
1446
+ }
1447
+
1365
1448
  return ifaceNames.length;
1366
1449
  }
1367
1450
 
@@ -1422,7 +1505,7 @@ export function installSingleTool(toolPath) {
1422
1505
 
1423
1506
  if (interfaces.claudeCodeHook) {
1424
1507
  if (isEnabled || isAlreadyDeployed) {
1425
- if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook)) installed++;
1508
+ if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook, toolName)) installed++;
1426
1509
  } else {
1427
1510
  skip(`Hook: ${toolName} not enabled`);
1428
1511
  }
@@ -1430,7 +1513,7 @@ export function installSingleTool(toolPath) {
1430
1513
 
1431
1514
  if (interfaces.skill) {
1432
1515
  // Skills always deploy. They're instruction files, not running code.
1433
- if (installSkill(toolPath, toolName)) installed++;
1516
+ if (installSkill(toolPath, toolName, interfaces.skill)) installed++;
1434
1517
  }
1435
1518
 
1436
1519
  if (interfaces.module) {
@@ -1591,7 +1674,7 @@ export async function enableExtension(name) {
1591
1674
 
1592
1675
  if (interfaces.mcp) registerMCP(extPath, interfaces.mcp, name);
1593
1676
  if (interfaces.claudeCodeHook) installClaudeCodeHook(extPath, interfaces.claudeCodeHook);
1594
- if (interfaces.skill) installSkill(extPath, name);
1677
+ if (interfaces.skill) installSkill(extPath, name, interfaces.skill);
1595
1678
 
1596
1679
  entry.enabled = true;
1597
1680
  entry.updatedAt = new Date().toISOString();
package/lib/detect.mjs CHANGED
@@ -15,6 +15,23 @@ function readJSON(path) {
15
15
  }
16
16
  }
17
17
 
18
+ function detectSkillDirectories(repoPath) {
19
+ const skillsDir = join(repoPath, 'skills');
20
+ if (!existsSync(skillsDir)) return [];
21
+
22
+ try {
23
+ return readdirSync(skillsDir, { withFileTypes: true })
24
+ .filter(e => e.isDirectory() && existsSync(join(skillsDir, e.name, 'SKILL.md')))
25
+ .map(e => ({
26
+ name: e.name,
27
+ path: join(skillsDir, e.name),
28
+ skillPath: join(skillsDir, e.name, 'SKILL.md'),
29
+ }));
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
18
35
  /**
19
36
  * Detect all interfaces in a repo.
20
37
  * Returns { interfaces, pkg } where interfaces is an object keyed by interface type.
@@ -48,9 +65,18 @@ export function detectInterfaces(repoPath) {
48
65
  interfaces.openclaw = { config: readJSON(ocPlugin), path: ocPlugin };
49
66
  }
50
67
 
51
- // 5. Skill: SKILL.md exists
52
- if (existsSync(join(repoPath, 'SKILL.md'))) {
53
- interfaces.skill = { path: join(repoPath, 'SKILL.md') };
68
+ // 5. Skill: root SKILL.md, or a skills/<name>/SKILL.md collection.
69
+ const rootSkillPath = join(repoPath, 'SKILL.md');
70
+ if (existsSync(rootSkillPath)) {
71
+ interfaces.skill = { path: rootSkillPath };
72
+ }
73
+
74
+ const skillDirs = detectSkillDirectories(repoPath);
75
+ if (skillDirs.length > 0) {
76
+ interfaces.skill = {
77
+ path: join(repoPath, 'skills'),
78
+ skills: skillDirs,
79
+ };
54
80
  }
55
81
 
56
82
  // 6. Claude Code Hook: guard.mjs or claudeCode.hook(s) in package.json
@@ -103,7 +129,12 @@ export function describeInterfaces(interfaces) {
103
129
  if (interfaces.module) lines.push(`Module: ${JSON.stringify(interfaces.module.main)}`);
104
130
  if (interfaces.mcp) lines.push(`MCP Server: ${interfaces.mcp.file}`);
105
131
  if (interfaces.openclaw) lines.push(`OpenClaw Plugin: ${interfaces.openclaw.config?.name || 'detected'}`);
106
- if (interfaces.skill) lines.push(`Skill: SKILL.md`);
132
+ if (interfaces.skill?.skills?.length) {
133
+ const skills = interfaces.skill.skills.map(s => `${s.name} (${s.skillPath})`);
134
+ lines.push(`Skill: ${skills.join(', ')}`);
135
+ } else if (interfaces.skill) {
136
+ lines.push(`Skill: SKILL.md`);
137
+ }
107
138
  if (interfaces.claudeCodeHook) {
108
139
  const events = interfaces.claudeCodeHook.map(h => h.event || 'PreToolUse');
109
140
  lines.push(`Claude Code Hook: ${events.join(', ')}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.85-alpha.1",
3
+ "version": "0.4.85-alpha.10",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -22,9 +22,17 @@
22
22
  "validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
23
23
  "test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
24
24
  "test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
25
+ "test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
26
+ "test:installer-skill-directory": "node scripts/test-installer-skill-directory.mjs",
27
+ "test:installer-skill-dry-run-destinations": "node scripts/test-installer-skill-dry-run-destinations.mjs",
25
28
  "test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
26
29
  "test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
27
30
  "test:bin-manifest": "node scripts/test-bin-manifest.mjs",
31
+ "test:crc-agentid-tenant-boundary": "node scripts/test-crc-agentid-tenant-boundary.mjs",
32
+ "test:crc-pair-login-flow": "node scripts/test-crc-pair-login-flow.mjs",
33
+ "test:crc-pair-status-poll-token": "node scripts/test-crc-pair-status-poll-token.mjs",
34
+ "test:crc-e2ee-key-persistence": "node scripts/test-crc-e2ee-key-persistence.mjs",
35
+ "test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
28
36
  "fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
29
37
  "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
30
38
  },
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
5
+
6
+ function assertContains(needle, label) {
7
+ if (!server.includes(needle)) {
8
+ throw new Error(`${label} missing expected text: ${needle}`);
9
+ }
10
+ }
11
+
12
+ function assertNotContains(needle, label) {
13
+ if (server.includes(needle)) {
14
+ throw new Error(`${label} still contains forbidden text: ${needle}`);
15
+ }
16
+ }
17
+
18
+ assertContains('const ACCOUNT_TENANT_PREFIX = "acct:";', "account tenant prefix");
19
+ assertContains('const LEGACY_API_KEY_TENANT_PREFIX = "key:";', "legacy key tenant prefix");
20
+ assertContains('function accountTenantIdForUserId(userId)', "account tenant helper");
21
+ assertContains('function identityForApiKey(key)', "api key identity helper");
22
+ assertContains('return identityForApiKey(key);', "http auth uses identity helper");
23
+ assertContains("const agentId = accountTenantIdForUserId(stored.userId);", "registration uses immutable account tenant");
24
+ assertContains("function sanitizeDisplayLabel(raw)", "display label sanitizer");
25
+ assertContains('replace(/[\\u0000-\\u001f\\u007f]/g, "").replace(/\\s+/g, " ").trim().slice(0, 64)', "display label sanitizer preserves label semantics");
26
+ assertContains("const displayLabel = sanitizeDisplayLabel(body?.displayName || body?.username);", "registration treats entered name as display label");
27
+ assertContains("displayLabel,", "registration challenge stores display label");
28
+ assertContains("await saveApiKey(apiKey, agentId, { handle: credentialLabel });", "registration stores handle separately");
29
+ assertContains("p.handle = identity.handle;", "pair stores display handle separately");
30
+ assertContains("handle: identity.handle,", "relay metadata returns display handle");
31
+ assertContains("codexDaemons.has(identity.agentId)", "daemon presence uses tenant id");
32
+ assertContains("codexDaemonPubkeys.get(identity.agentId)", "daemon pubkey uses tenant id");
33
+ assertContains("agentId: identity.agentId,", "relay tickets bind tenant id");
34
+ assertContains("handle: identity.handle,", "relay tickets preserve display handle");
35
+ assertContains("codexDaemons.set(identity.agentId, ws);", "daemon ws keyed by tenant id");
36
+ assertContains("const key = codexRelayKey(identity.agentId, threadId);", "web ws keyed by tenant id");
37
+ assertContains("const daemonWs = codexDaemons.get(identity.agentId);", "web sends to tenant daemon");
38
+ assertNotContains("const agentId = stored.username || (\"passkey-\"", "registration must not use chosen handle as tenant");
39
+ assertNotContains("const existingKey = Object.entries(API_KEYS).find(([k, v]) => v === agentId);", "oauth must not reuse chosen handle as tenant");
40
+ assertNotContains("function isUsernameTaken", "display labels must not be globally unique usernames");
41
+ assertNotContains("function sanitizeUsername", "display labels must not be modeled as usernames");
42
+ assertNotContains('json(res, 409, { error: "reserved_handle"', "display labels must not be blocked as reserved security handles");
43
+ assertNotContains('json(res, 409, { error: "handle_taken"', "duplicate display labels must be allowed");
44
+
45
+ function legacyTenantIdForApiKey(key) {
46
+ return "key:" + createHash("sha256").update(key).digest("base64url").slice(0, 32);
47
+ }
48
+
49
+ function accountTenantIdForUserId(userId) {
50
+ return "acct:" + userId;
51
+ }
52
+
53
+ const chosenHandle = "parker-smoke-test";
54
+ const sharedDisplayLabel = "Parker";
55
+ const accountA = accountTenantIdForUserId("user-a");
56
+ const accountB = accountTenantIdForUserId("user-b");
57
+ const threadId = "thread-019dfa";
58
+ if (accountA === accountB) {
59
+ throw new Error("different user ids collapsed to one account tenant");
60
+ }
61
+ if (`${sharedDisplayLabel}:${threadId}` === `${accountA}:${threadId}` || `${sharedDisplayLabel}:${threadId}` === `${accountB}:${threadId}`) {
62
+ throw new Error("display label was used as a relay route key");
63
+ }
64
+
65
+ const legacyA = legacyTenantIdForApiKey("ck-a");
66
+ const legacyB = legacyTenantIdForApiKey("ck-b");
67
+ if (legacyA === legacyB) {
68
+ throw new Error("legacy API-key tenants collided");
69
+ }
70
+
71
+ const webKeyA = `${accountA}:${threadId}`;
72
+ const webKeyB = `${accountB}:${threadId}`;
73
+ if (webKeyA === webKeyB) {
74
+ throw new Error("same display handle can still collide across account tenants");
75
+ }
76
+ if (`${chosenHandle}:${threadId}` === webKeyA || `${chosenHandle}:${threadId}` === webKeyB) {
77
+ throw new Error("model still keys relay routes by display handle");
78
+ }
79
+
80
+ console.log("crc agentId tenant boundary checks passed");
@@ -0,0 +1,80 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
4
+
5
+ function assertContains(needle, label) {
6
+ if (!server.includes(needle)) {
7
+ throw new Error(`${label} missing expected text: ${needle}`);
8
+ }
9
+ }
10
+
11
+ function assertBefore(first, second, label) {
12
+ const firstIndex = server.indexOf(first);
13
+ const secondIndex = server.indexOf(second);
14
+ if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
15
+ throw new Error(`${label} expected "${first}" before "${second}"`);
16
+ }
17
+ }
18
+
19
+ assertContains("CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_keys", "persistent key table");
20
+ assertContains("async function loadCodexDaemonPubkeysFromDb()", "boot load helper");
21
+ assertContains("await loadCodexDaemonPubkeysFromDb();", "boot load call");
22
+ assertContains("async function persistCodexDaemonPubkey(agentId, pubkey, cryptoVersions)", "persist helper");
23
+ assertContains("function registerCodexDaemonPubkey(agentId, pubkey, cryptoVersions, source)", "registration helper");
24
+ assertContains("codexDaemonPubkeys.set(agentId, {", "registration updates in-memory bootstrap cache");
25
+ assertContains("return persistCodexDaemonPubkey(agentId, pubkey, normalizedVersions)", "registration persists after cache update");
26
+ assertContains("await registerCodexDaemonPubkey(identity.agentId, p.daemon_public_key, p.crypto_versions, \"pair-complete\");", "pair-complete persists key");
27
+ assertContains("if (envelope?.type === \"daemon.identity\") {", "daemon reconnect identity frame");
28
+ assertContains("envelope.daemon_public_key", "daemon identity carries public key");
29
+ assertContains("envelope.crypto_versions", "daemon identity carries crypto versions");
30
+ assertContains("\"daemon-reconnect\"", "daemon reconnect source marker");
31
+ assertContains("const daemonKey = codexDaemonPubkeys.get(identity.agentId) || null;", "bootstrap uses loaded or registered key");
32
+ assertBefore(
33
+ "await loadCodexDaemonPubkeysFromDb();",
34
+ "function handleCodexBootstrap(req, res, threadId)",
35
+ "persisted keys load before bootstrap handler",
36
+ );
37
+
38
+ const modelPubkeys = new Map();
39
+ const persistedRows = new Map();
40
+
41
+ function normalizeVersions(versions) {
42
+ const out = Array.isArray(versions)
43
+ ? versions.filter((v) => typeof v === "string" && v.length > 0 && v.length <= 32).slice(0, 8)
44
+ : [];
45
+ return out.length ? out : ["e2ee-v1"];
46
+ }
47
+
48
+ function modelRegister(agentId, pubkey, versions) {
49
+ const normalized = normalizeVersions(versions);
50
+ modelPubkeys.set(agentId, { pubkey, crypto_versions: normalized });
51
+ persistedRows.set(agentId, { pubkey, crypto_versions_json: JSON.stringify(normalized) });
52
+ }
53
+
54
+ function modelBootLoad() {
55
+ const restored = new Map();
56
+ for (const [agentId, row] of persistedRows) {
57
+ restored.set(agentId, {
58
+ pubkey: row.pubkey,
59
+ crypto_versions: normalizeVersions(JSON.parse(row.crypto_versions_json)),
60
+ });
61
+ }
62
+ return restored;
63
+ }
64
+
65
+ modelRegister("acct:user-a", "spki-key-a", ["e2ee-v1"]);
66
+ const afterRestart = modelBootLoad();
67
+ if (afterRestart.get("acct:user-a")?.pubkey !== "spki-key-a") {
68
+ throw new Error("boot load did not restore persisted daemon pubkey");
69
+ }
70
+
71
+ modelRegister("acct:user-a", "spki-key-b", []);
72
+ const afterReconnect = modelBootLoad();
73
+ if (afterReconnect.get("acct:user-a")?.pubkey !== "spki-key-b") {
74
+ throw new Error("daemon reconnect did not replace persisted daemon pubkey");
75
+ }
76
+ if (afterReconnect.get("acct:user-a")?.crypto_versions?.[0] !== "e2ee-v1") {
77
+ throw new Error("daemon reconnect did not default crypto version");
78
+ }
79
+
80
+ console.log("crc e2ee key persistence checks passed");