@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.5
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 +1 -1
- package/docs/universal-installer/SPEC.md +14 -3
- package/docs/universal-installer/TECHNICAL.md +4 -4
- package/lib/deploy.mjs +61 -20
- package/lib/detect.mjs +35 -4
- package/package.json +5 -1
- package/scripts/test-crc-agentid-tenant-boundary.mjs +72 -0
- package/scripts/test-crc-e2ee-session-route.mjs +122 -0
- package/scripts/test-crc-pair-login-flow.mjs +40 -0
- package/scripts/test-installer-skill-directory.mjs +55 -0
- package/src/hosted-mcp/app/footer.js +74 -0
- package/src/hosted-mcp/app/kaleidoscope-login.html +843 -0
- package/src/hosted-mcp/app/pair.html +147 -57
- package/src/hosted-mcp/app/sprites.png +0 -0
- package/src/hosted-mcp/demo/index.html +3 -7
- package/src/hosted-mcp/demo/login.html +318 -20
- package/src/hosted-mcp/deploy.sh +306 -56
- package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
- package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
- package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
- package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
- package/src/hosted-mcp/server.mjs +775 -112
package/bin/ldm.js
CHANGED
|
@@ -4005,7 +4005,7 @@ async function main() {
|
|
|
4005
4005
|
console.log(' Module ... ESM main/exports -> importable');
|
|
4006
4006
|
console.log(' MCP Server ... mcp-server.mjs -> claude mcp add --scope user');
|
|
4007
4007
|
console.log(' OpenClaw ... openclaw.plugin.json -> ~/.ldm/extensions/ + ~/.openclaw/extensions/');
|
|
4008
|
-
console.log(' Skill ... SKILL.md
|
|
4008
|
+
console.log(' Skill ... SKILL.md or skills/<name>/SKILL.md -> agent skill paths');
|
|
4009
4009
|
console.log(' CC Hook ... guard.mjs or claudeCode.hook -> ~/.claude/settings.json');
|
|
4010
4010
|
console.log('');
|
|
4011
4011
|
console.log(` v${PKG_VERSION}`);
|
|
@@ -144,13 +144,13 @@ 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
|
|
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:**
|
|
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
154
|
|
|
155
155
|
**Structure:**
|
|
156
156
|
```
|
|
@@ -162,6 +162,17 @@ repo/
|
|
|
162
162
|
└── ...
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
+
Multiple skills can also live in one repo:
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
repo/
|
|
169
|
+
└── skills/
|
|
170
|
+
└── wip-ai-chat-ui/
|
|
171
|
+
├── SKILL.md
|
|
172
|
+
├── agents/
|
|
173
|
+
└── references/
|
|
174
|
+
```
|
|
175
|
+
|
|
165
176
|
**Key rules (from Agent Skills Spec):**
|
|
166
177
|
- SKILL.md body < 5000 tokens. Process goes in SKILL.md, context goes in references/.
|
|
167
178
|
- 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/`
|
|
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
|
|
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 |
|
|
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
|
}
|
|
@@ -1236,11 +1244,25 @@ function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath
|
|
|
1236
1244
|
}
|
|
1237
1245
|
}
|
|
1238
1246
|
|
|
1239
|
-
function
|
|
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
|
+
function installSkillFolder(skillDir, toolName, opts = {}) {
|
|
1240
1262
|
const { harnesses, workspace } = getHarnesses();
|
|
1241
1263
|
|
|
1242
|
-
// Find SKILL.md source:
|
|
1243
|
-
let skillSrc = join(
|
|
1264
|
+
// Find SKILL.md source: skill dir first, then permanent copy at ~/.ldm/extensions/
|
|
1265
|
+
let skillSrc = join(skillDir, 'SKILL.md');
|
|
1244
1266
|
const permanentSkill = join(LDM_EXTENSIONS, toolName, 'SKILL.md');
|
|
1245
1267
|
if (!existsSync(skillSrc) && existsSync(permanentSkill)) skillSrc = permanentSkill;
|
|
1246
1268
|
if (!existsSync(skillSrc)) return false;
|
|
@@ -1251,14 +1273,25 @@ function installSkill(repoPath, toolName) {
|
|
|
1251
1273
|
return false;
|
|
1252
1274
|
}
|
|
1253
1275
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1276
|
+
const sourceSkillDir = dirname(skillSrc);
|
|
1277
|
+
|
|
1278
|
+
// Find references/ source: skill dir first, then permanent copy
|
|
1279
|
+
let refsSrc = join(skillDir, 'references');
|
|
1256
1280
|
const permanentRefs = join(LDM_EXTENSIONS, toolName, 'references');
|
|
1257
1281
|
if (!existsSync(refsSrc) && existsSync(permanentRefs)) refsSrc = permanentRefs;
|
|
1258
1282
|
|
|
1259
1283
|
if (DRY_RUN) {
|
|
1260
|
-
const targets = Object.entries(harnesses)
|
|
1261
|
-
|
|
1284
|
+
const targets = Object.entries(harnesses)
|
|
1285
|
+
.filter(([,h]) => h.detected && h.skills)
|
|
1286
|
+
.map(([,h]) => join(h.skills, toolName));
|
|
1287
|
+
ok(`Skill: ${toolName}`);
|
|
1288
|
+
log(`Source: ${sourceSkillDir}`);
|
|
1289
|
+
if (targets.length > 0) {
|
|
1290
|
+
log(`Targets:`);
|
|
1291
|
+
for (const target of targets) log(`- ${target}`);
|
|
1292
|
+
} else {
|
|
1293
|
+
log(`Targets: no detected skill harnesses`);
|
|
1294
|
+
}
|
|
1262
1295
|
return true;
|
|
1263
1296
|
}
|
|
1264
1297
|
|
|
@@ -1267,21 +1300,13 @@ function installSkill(repoPath, toolName) {
|
|
|
1267
1300
|
|
|
1268
1301
|
// 1. Save permanent copy to ~/.ldm/extensions/<name>/ (survives tmp cleanup)
|
|
1269
1302
|
const ldmSkillDir = join(LDM_EXTENSIONS, toolName);
|
|
1270
|
-
|
|
1271
|
-
cpSync(skillSrc, join(ldmSkillDir, 'SKILL.md'));
|
|
1272
|
-
if (existsSync(refsSrc) && refsSrc !== permanentRefs) {
|
|
1273
|
-
cpSync(refsSrc, join(ldmSkillDir, 'references'), { recursive: true });
|
|
1274
|
-
}
|
|
1303
|
+
copySkillTree(sourceSkillDir, ldmSkillDir, opts.copyFullFolder);
|
|
1275
1304
|
|
|
1276
1305
|
// 2. Deploy to every detected harness that has a skills path
|
|
1277
1306
|
for (const [name, harness] of Object.entries(harnesses)) {
|
|
1278
1307
|
if (!harness.detected || !harness.skills) continue;
|
|
1279
1308
|
const dest = join(harness.skills, toolName);
|
|
1280
|
-
|
|
1281
|
-
cpSync(skillSrc, join(dest, 'SKILL.md'));
|
|
1282
|
-
if (existsSync(refsSrc)) {
|
|
1283
|
-
cpSync(refsSrc, join(dest, 'references'), { recursive: true });
|
|
1284
|
-
}
|
|
1309
|
+
copySkillTree(sourceSkillDir, dest, opts.copyFullFolder);
|
|
1285
1310
|
deployed.push(name);
|
|
1286
1311
|
}
|
|
1287
1312
|
|
|
@@ -1301,6 +1326,18 @@ function installSkill(repoPath, toolName) {
|
|
|
1301
1326
|
}
|
|
1302
1327
|
}
|
|
1303
1328
|
|
|
1329
|
+
function installSkill(repoPath, toolName, skillInfo = null) {
|
|
1330
|
+
if (Array.isArray(skillInfo?.skills) && skillInfo.skills.length > 0) {
|
|
1331
|
+
let installed = 0;
|
|
1332
|
+
for (const skill of skillInfo.skills) {
|
|
1333
|
+
if (installSkillFolder(skill.path, skill.name, { copyFullFolder: true })) installed++;
|
|
1334
|
+
}
|
|
1335
|
+
return installed > 0;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
return installSkillFolder(repoPath, toolName);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1304
1341
|
// ── Single tool install ──
|
|
1305
1342
|
|
|
1306
1343
|
export function installSingleTool(toolPath) {
|
|
@@ -1361,6 +1398,10 @@ export function installSingleTool(toolPath) {
|
|
|
1361
1398
|
}
|
|
1362
1399
|
}
|
|
1363
1400
|
|
|
1401
|
+
if (interfaces.skill) {
|
|
1402
|
+
installSkill(toolPath, toolName, interfaces.skill);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1364
1405
|
return ifaceNames.length;
|
|
1365
1406
|
}
|
|
1366
1407
|
|
|
@@ -1429,7 +1470,7 @@ export function installSingleTool(toolPath) {
|
|
|
1429
1470
|
|
|
1430
1471
|
if (interfaces.skill) {
|
|
1431
1472
|
// Skills always deploy. They're instruction files, not running code.
|
|
1432
|
-
if (installSkill(toolPath, toolName)) installed++;
|
|
1473
|
+
if (installSkill(toolPath, toolName, interfaces.skill)) installed++;
|
|
1433
1474
|
}
|
|
1434
1475
|
|
|
1435
1476
|
if (interfaces.module) {
|
|
@@ -1590,7 +1631,7 @@ export async function enableExtension(name) {
|
|
|
1590
1631
|
|
|
1591
1632
|
if (interfaces.mcp) registerMCP(extPath, interfaces.mcp, name);
|
|
1592
1633
|
if (interfaces.claudeCodeHook) installClaudeCodeHook(extPath, interfaces.claudeCodeHook);
|
|
1593
|
-
if (interfaces.skill) installSkill(extPath, name);
|
|
1634
|
+
if (interfaces.skill) installSkill(extPath, name, interfaces.skill);
|
|
1594
1635
|
|
|
1595
1636
|
entry.enabled = true;
|
|
1596
1637
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
|
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.
|
|
3
|
+
"version": "0.4.85-alpha.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
|
|
6
6
|
"engines": {
|
|
@@ -23,9 +23,13 @@
|
|
|
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
25
|
"test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
|
|
26
|
+
"test:installer-skill-directory": "node scripts/test-installer-skill-directory.mjs",
|
|
26
27
|
"test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
|
|
27
28
|
"test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
|
|
28
29
|
"test:bin-manifest": "node scripts/test-bin-manifest.mjs",
|
|
30
|
+
"test:crc-agentid-tenant-boundary": "node scripts/test-crc-agentid-tenant-boundary.mjs",
|
|
31
|
+
"test:crc-pair-login-flow": "node scripts/test-crc-pair-login-flow.mjs",
|
|
32
|
+
"test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
|
|
29
33
|
"fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
|
|
30
34
|
"fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
|
|
31
35
|
},
|
|
@@ -0,0 +1,72 @@
|
|
|
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('const RESERVED_AGENT_HANDLES = new Set([', "reserved handle set");
|
|
21
|
+
assertContains('"parker-smoke-test",', "reserved parker smoke handle");
|
|
22
|
+
assertContains('function accountTenantIdForUserId(userId)', "account tenant helper");
|
|
23
|
+
assertContains('function identityForApiKey(key)', "api key identity helper");
|
|
24
|
+
assertContains('return identityForApiKey(key);', "http auth uses identity helper");
|
|
25
|
+
assertContains("const agentId = accountTenantIdForUserId(stored.userId);", "registration uses immutable account tenant");
|
|
26
|
+
assertContains("await saveApiKey(apiKey, agentId, { handle: credentialLabel });", "registration stores handle separately");
|
|
27
|
+
assertContains('json(res, 409, { error: "reserved_handle"', "reserved handle rejected");
|
|
28
|
+
assertContains('json(res, 409, { error: "handle_taken"', "duplicate handle rejected");
|
|
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
|
+
|
|
41
|
+
function legacyTenantIdForApiKey(key) {
|
|
42
|
+
return "key:" + createHash("sha256").update(key).digest("base64url").slice(0, 32);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function accountTenantIdForUserId(userId) {
|
|
46
|
+
return "acct:" + userId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const chosenHandle = "parker-smoke-test";
|
|
50
|
+
const accountA = accountTenantIdForUserId("user-a");
|
|
51
|
+
const accountB = accountTenantIdForUserId("user-b");
|
|
52
|
+
if (accountA === accountB) {
|
|
53
|
+
throw new Error("different user ids collapsed to one account tenant");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const legacyA = legacyTenantIdForApiKey("ck-a");
|
|
57
|
+
const legacyB = legacyTenantIdForApiKey("ck-b");
|
|
58
|
+
if (legacyA === legacyB) {
|
|
59
|
+
throw new Error("legacy API-key tenants collided");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const threadId = "thread-019dfa";
|
|
63
|
+
const webKeyA = `${accountA}:${threadId}`;
|
|
64
|
+
const webKeyB = `${accountB}:${threadId}`;
|
|
65
|
+
if (webKeyA === webKeyB) {
|
|
66
|
+
throw new Error("same display handle can still collide across account tenants");
|
|
67
|
+
}
|
|
68
|
+
if (`${chosenHandle}:${threadId}` === webKeyA || `${chosenHandle}:${threadId}` === webKeyB) {
|
|
69
|
+
throw new Error("model still keys relay routes by display handle");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log("crc agentId tenant boundary checks passed");
|
|
@@ -0,0 +1,122 @@
|
|
|
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 = firstIndex === -1 ? -1 : server.indexOf(second, firstIndex + first.length);
|
|
14
|
+
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
|
|
15
|
+
throw new Error(`${label} expected "${first}" before "${second}"`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
assertContains("const codexWebClients = new Map(); // `${agentId}:${threadId}` -> Set<ws>", "thread-keyed web client sets");
|
|
20
|
+
assertContains("const codexE2eeSessionRoutes = new Map(); // `${agentId}:${e2eeSession}` -> { threadId, webKey, ws }", "e2ee session route map");
|
|
21
|
+
assertContains("function registerCodexE2eeSessionRoute(agentId, e2eeSession, threadId, ws)", "route registration helper");
|
|
22
|
+
assertContains("codexE2eeSessionRoutes.set(codexRelayKey(agentId, e2eeSession), { threadId, webKey, ws });", "route map stores e2ee session to thread");
|
|
23
|
+
assertContains("function addCodexWebClient(webKey, ws)", "web client set add helper");
|
|
24
|
+
assertContains("function removeCodexWebClient(webKey, ws)", "web client set remove helper");
|
|
25
|
+
assertContains("function openCodexWebClientsForKey(webKey)", "web client set read helper");
|
|
26
|
+
assertContains("function resolveCodexWebClientsForDaemonFrame(agentId, routeId)", "daemon route resolver");
|
|
27
|
+
assertContains("const routed = codexE2eeSessionRoutes.get(codexRelayKey(agentId, routeId));", "daemon route lookup uses e2ee session map");
|
|
28
|
+
assertContains("if (routed && routed.ws && routed.ws.readyState === routed.ws.OPEN) return [routed.ws];", "daemon route resolves to active owner socket");
|
|
29
|
+
assertContains("return openCodexWebClientsForKey(codexRelayKey(agentId, routeId));", "daemon route keeps direct thread fallback");
|
|
30
|
+
assertContains("const targets = resolveCodexWebClientsForDaemonFrame(identity.agentId, sessionId);", "daemon frames use route resolver");
|
|
31
|
+
assertContains("for (const target of targets) {", "daemon frames send to every resolved target");
|
|
32
|
+
assertContains("if (isCodexE2eeEnvelope(envelope) && envelope.session) {", "web e2ee messages are detected");
|
|
33
|
+
assertContains("registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);", "web e2ee session is registered");
|
|
34
|
+
assertContains("const clientCount = addCodexWebClient(key, ws);", "new web connections are added without replacing existing clients");
|
|
35
|
+
assertContains("removeCodexWebClient(key, ws);", "close cleanup removes only the closing socket");
|
|
36
|
+
assertContains("removeCodexE2eeRoutesForWeb(identity.agentId, threadId, ws);", "close cleanup");
|
|
37
|
+
assertContains("if (route.webKey === webKey && (!ws || route.ws === ws)) {", "cleanup only removes routes owned by the closing socket");
|
|
38
|
+
assertBefore(
|
|
39
|
+
"registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);",
|
|
40
|
+
"daemonWs.send(text);",
|
|
41
|
+
"web session route registered before forwarding to daemon",
|
|
42
|
+
);
|
|
43
|
+
if (server.includes("const previous = codexWebClients.get(key);")) {
|
|
44
|
+
throw new Error("web client replacement lookup is still present");
|
|
45
|
+
}
|
|
46
|
+
if (server.includes("codexWebClients.set(key, ws);")) {
|
|
47
|
+
throw new Error("web client singleton set is still present");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const OPEN = 1;
|
|
51
|
+
const agentId = "parker-smoke-test";
|
|
52
|
+
const threadId = "thread-123";
|
|
53
|
+
const e2eeSession = "e2ee-random-session-456";
|
|
54
|
+
if (e2eeSession === threadId) {
|
|
55
|
+
throw new Error("test setup must use a random E2EE session distinct from threadId");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const modelWebClients = new Map();
|
|
59
|
+
const modelRoutes = new Map();
|
|
60
|
+
const webSocketA = { readyState: OPEN, OPEN };
|
|
61
|
+
const webSocketB = { readyState: OPEN, OPEN };
|
|
62
|
+
const webSocketC = { readyState: OPEN, OPEN };
|
|
63
|
+
const webKey = `${agentId}:${threadId}`;
|
|
64
|
+
|
|
65
|
+
function modelAddWebClient(ws) {
|
|
66
|
+
let clients = modelWebClients.get(webKey);
|
|
67
|
+
if (!clients) {
|
|
68
|
+
clients = new Set();
|
|
69
|
+
modelWebClients.set(webKey, clients);
|
|
70
|
+
}
|
|
71
|
+
clients.add(ws);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function modelRegister(session, ws) {
|
|
75
|
+
modelRoutes.set(`${agentId}:${session}`, { webKey, ws });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function modelResolve(routeId) {
|
|
79
|
+
const route = modelRoutes.get(`${agentId}:${routeId}`);
|
|
80
|
+
if (route && route.ws.readyState === route.ws.OPEN) return [route.ws];
|
|
81
|
+
const clients = modelWebClients.get(`${agentId}:${routeId}`) || new Set();
|
|
82
|
+
return [...clients].filter((webWs) => webWs.readyState === webWs.OPEN);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function modelRemoveOwnedRoutes(ws) {
|
|
86
|
+
for (const [routeKey, route] of modelRoutes) {
|
|
87
|
+
if (route.webKey === webKey && route.ws === ws) modelRoutes.delete(routeKey);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function modelRemoveWebClient(ws) {
|
|
92
|
+
const clients = modelWebClients.get(webKey);
|
|
93
|
+
if (!clients) return;
|
|
94
|
+
clients.delete(ws);
|
|
95
|
+
if (clients.size === 0) modelWebClients.delete(webKey);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
modelAddWebClient(webSocketA);
|
|
99
|
+
modelAddWebClient(webSocketB);
|
|
100
|
+
modelRegister(e2eeSession, webSocketA);
|
|
101
|
+
if (modelResolve(e2eeSession)[0] !== webSocketA) {
|
|
102
|
+
throw new Error("random E2EE session did not route to the owning thread web socket");
|
|
103
|
+
}
|
|
104
|
+
const threadTargets = modelResolve(threadId);
|
|
105
|
+
if (threadTargets.length !== 2 || !threadTargets.includes(webSocketA) || !threadTargets.includes(webSocketB)) {
|
|
106
|
+
throw new Error("direct thread fallback did not broadcast to every thread web socket");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
modelRemoveOwnedRoutes(webSocketA);
|
|
110
|
+
modelRemoveWebClient(webSocketA);
|
|
111
|
+
modelAddWebClient(webSocketC);
|
|
112
|
+
modelRegister(e2eeSession, webSocketC);
|
|
113
|
+
modelRemoveOwnedRoutes(webSocketA);
|
|
114
|
+
if (modelResolve(e2eeSession)[0] !== webSocketC) {
|
|
115
|
+
throw new Error("old socket cleanup removed or stole the replacement E2EE route");
|
|
116
|
+
}
|
|
117
|
+
const remainingThreadTargets = modelResolve(threadId);
|
|
118
|
+
if (remainingThreadTargets.length !== 2 || !remainingThreadTargets.includes(webSocketB) || !remainingThreadTargets.includes(webSocketC)) {
|
|
119
|
+
throw new Error("closing one web socket removed another browser client");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log("crc e2ee session route checks passed");
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
4
|
+
const loginFiles = [
|
|
5
|
+
"src/hosted-mcp/app/kaleidoscope-login.html",
|
|
6
|
+
"src/hosted-mcp/demo/login.html",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
function assertContains(source, needle, label) {
|
|
10
|
+
if (!source.includes(needle)) {
|
|
11
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertBefore(source, first, second, label) {
|
|
16
|
+
const firstIndex = source.indexOf(first);
|
|
17
|
+
const secondIndex = source.indexOf(second);
|
|
18
|
+
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
|
|
19
|
+
throw new Error(`${label} expected "${first}" before "${second}"`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
assertContains(server, "const PAIR_NEXT_REGEX = /^\\/pair\\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;", "server pair regex");
|
|
24
|
+
assertContains(server, "const REMOTE_CONTROL_NEXT_REGEX = /^\\/codex-remote-control\\/", "server remote-control regex");
|
|
25
|
+
assertContains(server, "purpose, // \"pair\" | null", "server stores pair purpose");
|
|
26
|
+
assertContains(server, "next: next || null, // sanitized `/pair/<CODE>` or null", "server stores sanitized next");
|
|
27
|
+
assertContains(server, "json(res, 200, { status: \"approved\", agentId: entry.agentId });", "server strips desktop pair status");
|
|
28
|
+
assertContains(server, "json(res, 200, { ok: true, next: entry.next });", "server returns next to phone approve");
|
|
29
|
+
|
|
30
|
+
for (const file of loginFiles) {
|
|
31
|
+
const html = readFileSync(file, "utf8");
|
|
32
|
+
assertContains(html, "function isPairNextOnDesktop()", `${file} desktop pair helper`);
|
|
33
|
+
assertContains(html, "} else if (isPairNextOnDesktop()) {", `${file} auto-start desktop pair QR`);
|
|
34
|
+
assertContains(html, "startQrLogin('', 'signin');", `${file} pair QR uses sign-in mode`);
|
|
35
|
+
assertContains(html, "if (approveResponse && typeof approveResponse.next === 'string' && isWhitelistedNext(approveResponse.next))", `${file} consumes approve next`);
|
|
36
|
+
assertContains(html, "if (urlNext && PAIR_NEXT_REGEX.test(urlNext))", `${file} desktop pair approved branch`);
|
|
37
|
+
assertBefore(html, "if (isPairNextOnDesktop() && !qrSessionMode)", "if (needsCustomQR() && !qrSessionMode)", `${file} create button forces pair QR before normal QR fallback`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log("crc pair login flow checks passed");
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
const home = mkdtempSync(join(tmpdir(), 'ldm-skill-dir-home-'));
|
|
7
|
+
const source = mkdtempSync(join(tmpdir(), 'ldm-skill-dir-source-'));
|
|
8
|
+
|
|
9
|
+
function assert(condition, message) {
|
|
10
|
+
if (!condition) throw new Error(message);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
process.env.HOME = home;
|
|
15
|
+
|
|
16
|
+
for (const dir of ['.claude', '.openclaw', '.codex', '.agents']) {
|
|
17
|
+
mkdirSync(join(home, dir), { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const skillDir = join(source, 'skills', 'wip-ai-chat-ui');
|
|
21
|
+
mkdirSync(join(skillDir, 'references'), { recursive: true });
|
|
22
|
+
mkdirSync(join(skillDir, 'agents'), { recursive: true });
|
|
23
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: wip-ai-chat-ui\ndescription: "test skill"\n---\n\n# Test Skill\n');
|
|
24
|
+
writeFileSync(join(skillDir, 'references', 'stack.md'), '# Stack\n');
|
|
25
|
+
writeFileSync(join(skillDir, 'agents', 'openai.yaml'), 'display_name: "WIP AI Chat UI"\n');
|
|
26
|
+
|
|
27
|
+
const { detectInterfacesJSON } = await import('../lib/detect.mjs');
|
|
28
|
+
const detected = detectInterfacesJSON(source);
|
|
29
|
+
assert(detected.interfaceCount === 1, 'skill directory repo should expose one interface');
|
|
30
|
+
assert(detected.interfaces.skill?.skills?.[0]?.name === 'wip-ai-chat-ui', 'skill directory name should be detected');
|
|
31
|
+
|
|
32
|
+
const { installFromPath } = await import('../lib/deploy.mjs');
|
|
33
|
+
const result = await installFromPath(source);
|
|
34
|
+
assert(result.interfaces === 1, 'skill directory install should process one interface');
|
|
35
|
+
|
|
36
|
+
for (const target of [
|
|
37
|
+
join(home, '.claude', 'skills', 'wip-ai-chat-ui'),
|
|
38
|
+
join(home, '.openclaw', 'skills', 'wip-ai-chat-ui'),
|
|
39
|
+
join(home, '.codex', 'skills', 'wip-ai-chat-ui'),
|
|
40
|
+
join(home, '.agents', 'skills', 'wip-ai-chat-ui'),
|
|
41
|
+
]) {
|
|
42
|
+
assert(existsSync(join(target, 'SKILL.md')), `${target} should include SKILL.md`);
|
|
43
|
+
assert(existsSync(join(target, 'references', 'stack.md')), `${target} should include references`);
|
|
44
|
+
assert(existsSync(join(target, 'agents', 'openai.yaml')), `${target} should include agents metadata`);
|
|
45
|
+
assert(!lstatSync(target).isSymbolicLink(), `${target} should be a deployed directory, not a symlink`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const codexSkill = readFileSync(join(home, '.codex', 'skills', 'wip-ai-chat-ui', 'SKILL.md'), 'utf8');
|
|
49
|
+
assert(codexSkill.includes('name: wip-ai-chat-ui'), 'Codex target should contain the expected skill');
|
|
50
|
+
|
|
51
|
+
console.log('installer skill directory regression passed');
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(home, { recursive: true, force: true });
|
|
54
|
+
rmSync(source, { recursive: true, force: true });
|
|
55
|
+
}
|