@wipcomputer/wip-ldm-os 0.4.85-alpha.1 → 0.4.85-alpha.11
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 +19 -1
- package/docs/universal-installer/SPEC.md +16 -3
- package/docs/universal-installer/TECHNICAL.md +4 -4
- package/lib/deploy.mjs +108 -25
- package/lib/detect.mjs +35 -4
- package/package.json +9 -1
- package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
- package/scripts/test-crc-e2ee-key-persistence.mjs +146 -0
- package/scripts/test-crc-e2ee-session-route.mjs +122 -0
- package/scripts/test-crc-pair-login-flow.mjs +40 -0
- package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
- package/scripts/test-installer-hook-toolname.mjs +80 -0
- package/scripts/test-installer-skill-directory.mjs +55 -0
- package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -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/codex-relay-e2ee-registry.mjs +114 -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 +816 -137
|
@@ -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,73 @@
|
|
|
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 assertNotContains(needle, label) {
|
|
12
|
+
if (server.includes(needle)) {
|
|
13
|
+
throw new Error(`${label} still contains forbidden text: ${needle}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
assertContains("function generateCodexPairPollToken()", "pair poll token generator");
|
|
18
|
+
assertContains('return "ppt_" + randomBytes(32).toString("base64url");', "pair poll token entropy");
|
|
19
|
+
assertContains("function getBearerToken(req)", "bearer token helper");
|
|
20
|
+
assertContains("const pollToken = generateCodexPairPollToken();", "pair-init mints poll token");
|
|
21
|
+
assertContains("poll_token: pollToken,", "pair state stores poll token");
|
|
22
|
+
assertContains("poll_token_used: false,", "pair state tracks token consumption");
|
|
23
|
+
assertContains("pair_poll_token: pollToken,", "pair-init returns poll token to daemon");
|
|
24
|
+
assertContains('json(res, 401, { error: "pair_poll_token_expired" });', "expired token rejected");
|
|
25
|
+
assertContains('json(res, 401, { error: "invalid_pair_poll_token" });', "missing or wrong token rejected");
|
|
26
|
+
assertContains("if (!pollToken || pollToken !== p.poll_token || p.poll_token_used)", "pair-status validates token");
|
|
27
|
+
assertContains("p.poll_token_used = true;", "completed credential response consumes token");
|
|
28
|
+
|
|
29
|
+
function pairStatusModel(pair, bearer, now) {
|
|
30
|
+
if (now > pair.expires) return { code: 401, body: { error: "pair_poll_token_expired" } };
|
|
31
|
+
if (!bearer || bearer !== pair.poll_token || pair.poll_token_used) {
|
|
32
|
+
return { code: 401, body: { error: "invalid_pair_poll_token" } };
|
|
33
|
+
}
|
|
34
|
+
if (pair.status === "completed") {
|
|
35
|
+
pair.poll_token_used = true;
|
|
36
|
+
return { code: 200, body: { status: "completed", api_key: pair.apiKey, handle: pair.handle } };
|
|
37
|
+
}
|
|
38
|
+
return { code: 200, body: { status: pair.status } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const pair = {
|
|
42
|
+
status: "pending",
|
|
43
|
+
expires: 10_000,
|
|
44
|
+
poll_token: "ppt_good",
|
|
45
|
+
poll_token_used: false,
|
|
46
|
+
apiKey: "ck_secret",
|
|
47
|
+
handle: "Parker",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (pairStatusModel({ ...pair }, null, 1).code !== 401) {
|
|
51
|
+
throw new Error("missing poll token should fail");
|
|
52
|
+
}
|
|
53
|
+
if (pairStatusModel({ ...pair }, "ppt_wrong", 1).code !== 401) {
|
|
54
|
+
throw new Error("wrong poll token should fail");
|
|
55
|
+
}
|
|
56
|
+
if (pairStatusModel({ ...pair }, "ppt_good", 20_000).code !== 401) {
|
|
57
|
+
throw new Error("expired poll token should fail");
|
|
58
|
+
}
|
|
59
|
+
if (pairStatusModel({ ...pair }, "ppt_good", 1).body.status !== "pending") {
|
|
60
|
+
throw new Error("correct poll token should return pending before completion");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const completedPair = { ...pair, status: "completed" };
|
|
64
|
+
const completed = pairStatusModel(completedPair, "ppt_good", 1);
|
|
65
|
+
if (completed.code !== 200 || completed.body.api_key !== "ck_secret") {
|
|
66
|
+
throw new Error("correct poll token should return completed credential once");
|
|
67
|
+
}
|
|
68
|
+
const replay = pairStatusModel(completedPair, "ppt_good", 1);
|
|
69
|
+
if (replay.code !== 401) {
|
|
70
|
+
throw new Error("reused completed poll token should fail");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log("crc pair-status poll token checks passed");
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
const tempHome = mkdtempSync(join(tmpdir(), 'ldm-hook-toolname-home-'));
|
|
7
|
+
const tempPkg = mkdtempSync(join(tmpdir(), 'ldm-npm-pack-'));
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
process.env.HOME = tempHome;
|
|
11
|
+
|
|
12
|
+
mkdirSync(join(tempHome, '.claude'), { recursive: true });
|
|
13
|
+
writeFileSync(join(tempHome, '.claude', 'settings.json'), JSON.stringify({ hooks: {} }, null, 2) + '\n');
|
|
14
|
+
const staleExtDir = join(tempHome, '.ldm', 'extensions', 'wip-branch-guard');
|
|
15
|
+
mkdirSync(staleExtDir, { recursive: true });
|
|
16
|
+
writeFileSync(join(staleExtDir, 'guard.mjs'), 'console.log("stale guard");\n');
|
|
17
|
+
writeFileSync(join(staleExtDir, 'package.json'), JSON.stringify({
|
|
18
|
+
name: '@wipcomputer/wip-branch-guard',
|
|
19
|
+
version: '1.9.89',
|
|
20
|
+
}, null, 2) + '\n');
|
|
21
|
+
writeFileSync(join(tempHome, '.ldm', 'extensions', 'registry.json'), JSON.stringify({
|
|
22
|
+
_format: 'v2',
|
|
23
|
+
extensions: {
|
|
24
|
+
'wip-branch-guard': {
|
|
25
|
+
version: '1.9.89',
|
|
26
|
+
ldmPath: staleExtDir,
|
|
27
|
+
paths: { ldm: staleExtDir },
|
|
28
|
+
interfaces: ['module', 'skill', 'claudeCodeHook'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}, null, 2) + '\n');
|
|
32
|
+
|
|
33
|
+
const extractedPackageDir = join(tempPkg, 'package');
|
|
34
|
+
mkdirSync(extractedPackageDir, { recursive: true });
|
|
35
|
+
writeFileSync(join(extractedPackageDir, 'package.json'), JSON.stringify({
|
|
36
|
+
name: '@wipcomputer/wip-branch-guard',
|
|
37
|
+
version: '1.9.90',
|
|
38
|
+
type: 'module',
|
|
39
|
+
main: 'guard.mjs',
|
|
40
|
+
claudeCode: {
|
|
41
|
+
hooks: [
|
|
42
|
+
{ event: 'PreToolUse', matcher: 'Write|Edit|Bash', command: 'node guard.mjs', timeout: 5 },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
}, null, 2) + '\n');
|
|
46
|
+
writeFileSync(join(extractedPackageDir, 'guard.mjs'), 'console.log("guard 1.9.90");\n');
|
|
47
|
+
writeFileSync(join(extractedPackageDir, 'SKILL.md'), '---\nname: wip-branch-guard\ndescription: "test skill"\n---\n');
|
|
48
|
+
|
|
49
|
+
const { installSingleTool } = await import('../lib/deploy.mjs');
|
|
50
|
+
const installed = installSingleTool(extractedPackageDir);
|
|
51
|
+
if (installed === 0) throw new Error('installer did not process the test package');
|
|
52
|
+
|
|
53
|
+
const expectedDir = join(tempHome, '.ldm', 'extensions', 'wip-branch-guard');
|
|
54
|
+
const wrongDir = join(tempHome, '.ldm', 'extensions', 'package');
|
|
55
|
+
if (!existsSync(join(expectedDir, 'guard.mjs'))) {
|
|
56
|
+
throw new Error('guard.mjs was not deployed under the package-derived tool name');
|
|
57
|
+
}
|
|
58
|
+
if (!existsSync(join(expectedDir, 'package.json'))) {
|
|
59
|
+
throw new Error('package.json was not deployed under the package-derived tool name');
|
|
60
|
+
}
|
|
61
|
+
if (existsSync(wrongDir)) {
|
|
62
|
+
throw new Error('hook deployment used basename(repoPath) instead of package-derived tool name');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const settings = JSON.parse(readFileSync(join(tempHome, '.claude', 'settings.json'), 'utf8'));
|
|
66
|
+
const command = settings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command || '';
|
|
67
|
+
if (!command.includes('/wip-branch-guard/guard.mjs')) {
|
|
68
|
+
throw new Error(`hook command points at the wrong extension path: ${command}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const deployedPkg = JSON.parse(readFileSync(join(expectedDir, 'package.json'), 'utf8'));
|
|
72
|
+
if (deployedPkg.version !== '1.9.90') {
|
|
73
|
+
throw new Error(`deployed package version mismatch: ${deployedPkg.version}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log('installer hook tool-name regression check passed');
|
|
77
|
+
} finally {
|
|
78
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
79
|
+
rmSync(tempPkg, { recursive: true, force: true });
|
|
80
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, mkdtempSync, 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-dry-run-home-'));
|
|
7
|
+
const source = mkdtempSync(join(tmpdir(), 'ldm-skill-dry-run-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 workspace = join(home, 'workspace');
|
|
21
|
+
mkdirSync(workspace, { recursive: true });
|
|
22
|
+
mkdirSync(join(home, '.ldm'), { recursive: true });
|
|
23
|
+
writeFileSync(join(home, '.ldm', 'config.json'), JSON.stringify({
|
|
24
|
+
workspace,
|
|
25
|
+
harnesses: {
|
|
26
|
+
'claude-code': {
|
|
27
|
+
detected: true,
|
|
28
|
+
home: join(home, '.claude'),
|
|
29
|
+
skills: join(home, '.claude', 'skills'),
|
|
30
|
+
},
|
|
31
|
+
openclaw: {
|
|
32
|
+
detected: true,
|
|
33
|
+
home: join(home, '.openclaw'),
|
|
34
|
+
skills: join(home, '.openclaw', 'skills'),
|
|
35
|
+
},
|
|
36
|
+
codex: {
|
|
37
|
+
detected: true,
|
|
38
|
+
home: join(home, '.codex'),
|
|
39
|
+
skills: join(home, '.codex', 'skills'),
|
|
40
|
+
},
|
|
41
|
+
'wip-agents': {
|
|
42
|
+
detected: true,
|
|
43
|
+
home: join(home, '.agents'),
|
|
44
|
+
skills: join(home, '.agents', 'skills'),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}, null, 2));
|
|
48
|
+
|
|
49
|
+
mkdirSync(join(source, 'references'), { recursive: true });
|
|
50
|
+
mkdirSync(join(source, 'agents'), { recursive: true });
|
|
51
|
+
writeFileSync(join(source, 'package.json'), JSON.stringify({
|
|
52
|
+
name: '@wipcomputer/wip-ai-chat-ui',
|
|
53
|
+
version: '0.1.1',
|
|
54
|
+
}, null, 2));
|
|
55
|
+
writeFileSync(join(source, 'SKILL.md'), '---\nname: wip-ai-chat-ui\ndescription: "test skill"\n---\n\n# Test Skill\n');
|
|
56
|
+
writeFileSync(join(source, 'references', 'stack.md'), '# Stack\n');
|
|
57
|
+
writeFileSync(join(source, 'agents', 'openai.yaml'), 'display_name: "WIP AI Chat UI"\n');
|
|
58
|
+
|
|
59
|
+
const { setFlags, installFromPath } = await import('../lib/deploy.mjs');
|
|
60
|
+
|
|
61
|
+
const lines = [];
|
|
62
|
+
const originalLog = console.log;
|
|
63
|
+
console.log = (...args) => lines.push(args.join(' '));
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
setFlags({ dryRun: true, jsonOutput: false });
|
|
67
|
+
const result = await installFromPath(source);
|
|
68
|
+
assert(result.interfaces === 1, 'dry run should process one skill interface');
|
|
69
|
+
} finally {
|
|
70
|
+
console.log = originalLog;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const output = lines.join('\n');
|
|
74
|
+
|
|
75
|
+
for (const expected of [
|
|
76
|
+
'Would copy:',
|
|
77
|
+
'- SKILL.md',
|
|
78
|
+
'- references/',
|
|
79
|
+
'- agents/',
|
|
80
|
+
'Permanent copy:',
|
|
81
|
+
join(home, '.ldm', 'extensions', 'wip-ai-chat-ui', 'SKILL.md'),
|
|
82
|
+
join(home, '.ldm', 'extensions', 'wip-ai-chat-ui', 'references/'),
|
|
83
|
+
'Agent skill targets:',
|
|
84
|
+
`claude-code: ${join(home, '.claude', 'skills', 'wip-ai-chat-ui')}`,
|
|
85
|
+
join(home, '.claude', 'skills', 'wip-ai-chat-ui', 'SKILL.md'),
|
|
86
|
+
`openclaw: ${join(home, '.openclaw', 'skills', 'wip-ai-chat-ui')}`,
|
|
87
|
+
join(home, '.openclaw', 'skills', 'wip-ai-chat-ui', 'references/'),
|
|
88
|
+
`codex: ${join(home, '.codex', 'skills', 'wip-ai-chat-ui')}`,
|
|
89
|
+
`wip-agents: ${join(home, '.agents', 'skills', 'wip-ai-chat-ui')}`,
|
|
90
|
+
'Workspace docs target:',
|
|
91
|
+
`${join(workspace, 'settings', 'docs', 'skills', 'wip-ai-chat-ui')} (references/ only)`,
|
|
92
|
+
]) {
|
|
93
|
+
assert(output.includes(expected), `dry-run output should include ${expected}\n\n${output}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log('installer skill dry-run destinations regression passed');
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(home, { recursive: true, force: true });
|
|
99
|
+
rmSync(source, { recursive: true, force: true });
|
|
100
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Shared footer for all Kaleidoscope pages (production-owned).
|
|
2
|
+
// Include with: <div id="kscope-footer"></div><script src="/app/footer.js"></script>
|
|
3
|
+
(function() {
|
|
4
|
+
var container = document.getElementById('kscope-footer');
|
|
5
|
+
if (!container) return;
|
|
6
|
+
|
|
7
|
+
var mobile = navigator.maxTouchPoints > 0 && window.innerWidth < 768;
|
|
8
|
+
|
|
9
|
+
// Desktop: fixed at bottom. Mobile: in page flow (below fold).
|
|
10
|
+
if (mobile) {
|
|
11
|
+
container.style.cssText = 'background:#FFFDF5;padding:16px 0;';
|
|
12
|
+
} else {
|
|
13
|
+
container.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#FFFDF5;padding:16px 0;';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
var inner = document.createElement('div');
|
|
17
|
+
inner.style.cssText = 'max-width:980px;margin:0 auto;padding:0 24px;border-top:1px solid rgba(0,0,0,0.06);padding-top:16px;text-align:left;font-size:13px;color:#a8a4a0;line-height:1.6;';
|
|
18
|
+
|
|
19
|
+
// On mobile, copyright and links on separate lines (like Apple)
|
|
20
|
+
if (mobile) {
|
|
21
|
+
inner.innerHTML = '<p style="margin:0;">WIP Computer, Inc.</p>'
|
|
22
|
+
+ '<p style="margin:2px 0 0;">Learning Dreaming Machines</p>'
|
|
23
|
+
+ '<p style="margin:8px 0 0;">Copyright © 2026 WIP Computer, Inc. All rights reserved.</p>'
|
|
24
|
+
+ '<p style="margin:4px 0 0;">'
|
|
25
|
+
+ '<a href="/legal/privacy/en-ww/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> | '
|
|
26
|
+
+ '<a href="/legal/internet-services/terms/site.html" style="color:#a8a4a0;text-decoration:none;">Terms of Use</a></p>'
|
|
27
|
+
+ '<p style="margin:4px 0 0;">'
|
|
28
|
+
+ '<a href="/agent.txt" style="color:#a8a4a0;text-decoration:none;">Are you an AI Agent?</a></p>'
|
|
29
|
+
+ '<p style="margin:4px 0 0;">Made in California.</p>';
|
|
30
|
+
} else {
|
|
31
|
+
inner.innerHTML = '<p style="margin:0;">WIP Computer, Inc.</p>'
|
|
32
|
+
+ '<p style="margin:2px 0 0;">Learning Dreaming Machines</p>'
|
|
33
|
+
+ '<p style="margin:8px 0 0;">Copyright © 2026 WIP Computer, Inc. All rights reserved. '
|
|
34
|
+
+ '<a href="/legal/privacy/en-ww/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> | '
|
|
35
|
+
+ '<a href="/legal/internet-services/terms/site.html" style="color:#a8a4a0;text-decoration:none;">Terms of Use</a></p>'
|
|
36
|
+
+ '<p style="margin:4px 0 0;">'
|
|
37
|
+
+ '<a href="/agent.txt" style="color:#a8a4a0;text-decoration:none;">Are you an AI Agent?</a> | '
|
|
38
|
+
+ '<a id="localPasskeysToggle" onclick="toggleLocalPasskeys()" style="color:#a8a4a0;text-decoration:none;cursor:pointer;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
|
|
39
|
+
+ '<span id="passkeys-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;"></span> '
|
|
40
|
+
+ '<span id="passkeys-label">Local passkeys off</span></a></p>'
|
|
41
|
+
+ '<p style="margin:4px 0 0;">Made in California.</p>';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
container.appendChild(inner);
|
|
45
|
+
|
|
46
|
+
// Local passkeys toggle
|
|
47
|
+
if (!window.isLocalPasskeysOn) {
|
|
48
|
+
window.isLocalPasskeysOn = function() { return localStorage.getItem('localPasskeys') === 'on'; };
|
|
49
|
+
}
|
|
50
|
+
if (!window.toggleLocalPasskeys) {
|
|
51
|
+
window.toggleLocalPasskeys = function() {
|
|
52
|
+
var on = isLocalPasskeysOn();
|
|
53
|
+
localStorage.setItem('localPasskeys', on ? 'off' : 'on');
|
|
54
|
+
updatePasskeysDot();
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (!window.updatePasskeysDot) {
|
|
58
|
+
window.updatePasskeysDot = function() {
|
|
59
|
+
var dot = document.getElementById('passkeys-dot');
|
|
60
|
+
var label = document.getElementById('passkeys-label');
|
|
61
|
+
if (!dot) return;
|
|
62
|
+
if (isLocalPasskeysOn()) {
|
|
63
|
+
dot.style.background = '#2E7D32';
|
|
64
|
+
dot.style.opacity = '1';
|
|
65
|
+
if (label) label.textContent = 'Local passkeys on';
|
|
66
|
+
} else {
|
|
67
|
+
dot.style.background = '#D32F2F';
|
|
68
|
+
dot.style.opacity = '0.4';
|
|
69
|
+
if (label) label.textContent = 'Local passkeys off';
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
updatePasskeysDot();
|
|
74
|
+
})();
|