@wipcomputer/wip-ldm-os 0.4.85-alpha.2 → 0.4.85-alpha.20

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.
Files changed (36) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +8 -5
  3. package/bin/ldm.js +95 -51
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/package.json +12 -2
  9. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  10. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  11. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  12. package/scripts/test-crc-pair-login-flow.mjs +40 -0
  13. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  14. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  15. package/scripts/test-install-prompt-policy.mjs +60 -0
  16. package/scripts/test-installer-skill-directory.mjs +55 -0
  17. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  18. package/scripts/test-installer-target-self-update.mjs +131 -0
  19. package/shared/templates/install-prompt.md +20 -2
  20. package/src/hosted-mcp/README.md +15 -0
  21. package/src/hosted-mcp/app/footer.js +74 -0
  22. package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
  23. package/src/hosted-mcp/app/pair.html +165 -57
  24. package/src/hosted-mcp/app/sprites.png +0 -0
  25. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  26. package/src/hosted-mcp/demo/index.html +3 -7
  27. package/src/hosted-mcp/demo/login.html +318 -20
  28. package/src/hosted-mcp/deploy.sh +307 -56
  29. package/src/hosted-mcp/docs/self-host.md +268 -0
  30. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  31. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  32. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  33. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  34. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  35. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  36. package/src/hosted-mcp/server.mjs +963 -146
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const repoRoot = fileURLToPath(new URL("..", import.meta.url));
6
+
7
+ const files = [
8
+ "README.md",
9
+ "SKILL.md",
10
+ "shared/templates/install-prompt.md",
11
+ ];
12
+
13
+ const contents = Object.fromEntries(
14
+ files.map((file) => [file, readFileSync(join(repoRoot, file), "utf8")]),
15
+ );
16
+
17
+ const failures = [];
18
+
19
+ for (const file of ["README.md", "shared/templates/install-prompt.md"]) {
20
+ const text = contents[file];
21
+ for (const phrase of [
22
+ "Use the install document and live local checks as the source of truth. Do not search memory or prior notes for this install.",
23
+ "If installed: run `ldm status`",
24
+ "If yes to dry run, run `ldm install --dry-run`.",
25
+ "`npm install -g @wipcomputer/wip-ldm-os@latest && ldm install && ldm doctor`",
26
+ "Then run:\n`ldm init --dry-run`",
27
+ "If I say install, run:\n`ldm init`",
28
+ ]) {
29
+ if (!text.includes(phrase)) {
30
+ failures.push(`${file} missing install prompt phrase: ${phrase}`);
31
+ }
32
+ }
33
+ if (text.includes("If it is, run ldm install --dry-run")) {
34
+ failures.push(`${file} still tells installed users to start with ldm install --dry-run`);
35
+ }
36
+ }
37
+
38
+ const skill = contents["SKILL.md"];
39
+ for (const phrase of [
40
+ "Do not run GitHub release commands during the install-state flow.",
41
+ "Do not run `gh release list` or `gh release view` unless the user explicitly asks for release notes.",
42
+ "Use the output of `ldm status`, installed package metadata, and npm metadata.",
43
+ "Do not use GitHub release commands here.",
44
+ ]) {
45
+ if (!skill.includes(phrase)) {
46
+ failures.push(`SKILL.md missing install policy phrase: ${phrase}`);
47
+ }
48
+ }
49
+
50
+ if (/gh release (list|view) --repo/.test(skill)) {
51
+ failures.push("SKILL.md still includes concrete gh release commands in the install flow");
52
+ }
53
+
54
+ if (failures.length > 0) {
55
+ console.error("install prompt policy checks failed:");
56
+ for (const failure of failures) console.error(` - ${failure}`);
57
+ process.exit(1);
58
+ }
59
+
60
+ console.log("install-prompt-policy: LDM OS prompt and install doc agree");
@@ -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,131 @@
1
+ #!/usr/bin/env node
2
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const cli = readFileSync(join(root, 'bin', 'ldm.js'), 'utf8');
10
+
11
+ const helperName = 'function maybeSelfUpdateLdmCliBeforeInstall()';
12
+ const helperIdx = cli.indexOf(helperName);
13
+ if (helperIdx === -1) {
14
+ throw new Error('Missing shared LDM OS self-update preflight helper');
15
+ }
16
+
17
+ const helperBlock = cli.slice(helperIdx, cli.indexOf('// ── Dead backup trigger cleanup', helperIdx));
18
+ if (!helperBlock.includes('Dry run only: continuing with v${PKG_VERSION}.')) {
19
+ throw new Error('Self-update helper must warn on dry run without updating');
20
+ }
21
+
22
+ if (!helperBlock.includes("execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`")) {
23
+ throw new Error('Self-update helper must update LDM OS before real installs');
24
+ }
25
+
26
+ if (!helperBlock.includes("spawnSync('ldm'")) {
27
+ throw new Error('Self-update helper must re-run the original install command without shell joining args');
28
+ }
29
+
30
+ if (helperBlock.includes('process.argv.slice(2).join')) {
31
+ throw new Error('Self-update helper must preserve argv boundaries when re-running install');
32
+ }
33
+
34
+ const cmdInstallIdx = cli.indexOf('async function cmdInstall()');
35
+ const lockIdx = cli.indexOf('acquireInstallLock()', cmdInstallIdx);
36
+ const initIdx = cli.indexOf('LDM OS not initialized. Running init first', cmdInstallIdx);
37
+ const targetIdx = cli.indexOf('// Find the target (skip flags)', cmdInstallIdx);
38
+ const preflightCallIdx = cli.indexOf('maybeSelfUpdateLdmCliBeforeInstall();', cmdInstallIdx);
39
+ if (cmdInstallIdx === -1 || targetIdx === -1 || preflightCallIdx === -1) {
40
+ throw new Error('Could not find cmdInstall self-update placement');
41
+ }
42
+
43
+ if (preflightCallIdx > lockIdx || preflightCallIdx > initIdx) {
44
+ throw new Error('Self-update preflight must run before lock acquisition and init work');
45
+ }
46
+
47
+ if (preflightCallIdx > targetIdx) {
48
+ throw new Error('Self-update preflight must run before target resolution so app installs are covered');
49
+ }
50
+
51
+ const catalogIdx = cli.indexOf('async function cmdInstallCatalog()');
52
+ const oldCatalogBlock = cli.indexOf('Self-update: check if CLI itself is outdated', catalogIdx);
53
+ const autoDetectIdx = cli.indexOf('autoDetectExtensions();', catalogIdx);
54
+ if (oldCatalogBlock !== -1 && oldCatalogBlock < autoDetectIdx) {
55
+ throw new Error('Catalog install should not own the only self-update block');
56
+ }
57
+
58
+ const tempRoot = mkdtempSync(join(tmpdir(), 'ldm-target-self-update-'));
59
+ try {
60
+ const home = join(tempRoot, 'home');
61
+ const fakeBin = join(tempRoot, 'bin');
62
+ const target = join(tempRoot, 'target skill with spaces');
63
+
64
+ mkdirSync(join(home, '.ldm'), { recursive: true });
65
+ writeFileSync(join(home, '.ldm', 'version.json'), JSON.stringify({
66
+ version: '0.0.0',
67
+ installed: new Date().toISOString(),
68
+ updated: new Date().toISOString(),
69
+ }, null, 2) + '\n');
70
+
71
+ mkdirSync(fakeBin, { recursive: true });
72
+ const fakeNpm = join(fakeBin, 'npm');
73
+ writeFileSync(fakeNpm, `#!/usr/bin/env bash
74
+ if [ "$1" = "view" ] && [ "$2" = "@wipcomputer/wip-ldm-os" ] && [ "$3" = "dist-tags.alpha" ]; then
75
+ echo "99.0.0-alpha.1"
76
+ exit 0
77
+ fi
78
+ echo "unexpected npm command: $*" >&2
79
+ exit 64
80
+ `);
81
+ chmodSync(fakeNpm, 0o755);
82
+
83
+ mkdirSync(target, { recursive: true });
84
+ writeFileSync(join(target, 'SKILL.md'), `---
85
+ name: test-target-skill
86
+ description: Test target skill for installer self-update dry-run checks.
87
+ ---
88
+
89
+ # Test Target Skill
90
+ `);
91
+
92
+ const result = spawnSync(process.execPath, [
93
+ join(root, 'bin', 'ldm.js'),
94
+ 'install',
95
+ '--alpha',
96
+ '--dry-run',
97
+ target,
98
+ ], {
99
+ cwd: root,
100
+ encoding: 'utf8',
101
+ env: {
102
+ ...process.env,
103
+ HOME: home,
104
+ PATH: `${fakeBin}:${process.env.PATH || ''}`,
105
+ },
106
+ });
107
+
108
+ if (result.status !== 0) {
109
+ throw new Error(`Runtime dry-run exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
110
+ }
111
+
112
+ if (!result.stdout.includes('LDM OS CLI v')) {
113
+ throw new Error(`Runtime dry-run did not print the LDM OS skew warning\nstdout:\n${result.stdout}`);
114
+ }
115
+
116
+ if (!result.stdout.includes('-> v99.0.0-alpha.1 (alpha track) is available.')) {
117
+ throw new Error(`Runtime dry-run did not include the selected alpha track version\nstdout:\n${result.stdout}`);
118
+ }
119
+
120
+ if (!result.stdout.includes('Dry run only: continuing with v')) {
121
+ throw new Error(`Runtime dry-run did not say it would continue without updating\nstdout:\n${result.stdout}`);
122
+ }
123
+
124
+ if (!result.stdout.includes('Installing: target skill with spaces (dry run)')) {
125
+ throw new Error(`Runtime dry-run did not continue to the targeted install preview\nstdout:\n${result.stdout}`);
126
+ }
127
+ } finally {
128
+ rmSync(tempRoot, { recursive: true, force: true });
129
+ }
130
+
131
+ console.log('targeted install self-update regression checks passed');
@@ -6,7 +6,18 @@ Open your AI and paste this:
6
6
 
7
7
  Read https://wip.computer/install/wip-ldm-os.txt
8
8
 
9
- Check if LDM OS is already installed. If it is, run ldm install --dry-run and show me what I have and what's new.
9
+ Use the install document and live local checks as the source of truth. Do not search memory or prior notes for this install.
10
+
11
+ Check if LDM OS is installed (`which ldm && ldm --version`). Branch.
12
+
13
+ If installed: run `ldm status` and show me the version table and what's new for each component. Then ask:
14
+ - Do you have questions?
15
+ - Want to see a dry run?
16
+
17
+ If yes to dry run, run `ldm install --dry-run`.
18
+
19
+ If I say install, run:
20
+ `npm install -g @wipcomputer/wip-ldm-os@latest && ldm install && ldm doctor`
10
21
 
11
22
  If not, walk me through setup and explain:
12
23
 
@@ -19,7 +30,14 @@ Then ask:
19
30
  - Do you have questions?
20
31
  - Want to see a dry run?
21
32
 
22
- If I say yes: Install the CLI first (npm install -g @wipcomputer/wip-ldm-os) and then run ldm install --dry-run.
33
+ If yes to dry run, install the CLI first:
34
+ `npm install -g @wipcomputer/wip-ldm-os`
35
+
36
+ Then run:
37
+ `ldm init --dry-run`
38
+
39
+ If I say install, run:
40
+ `ldm init`
23
41
 
24
42
  Show me exactly what will change. Don't install anything until I say "install".
25
43
 
@@ -0,0 +1,15 @@
1
+ # Hosted MCP And Relay
2
+
3
+ This directory contains the public source for the hosted WIP relay that serves `wip.computer`.
4
+
5
+ It includes:
6
+
7
+ - OAuth, passkey, and hosted MCP routes in `server.mjs`;
8
+ - Codex Remote Control relay routes under `/api/codex-relay/*`;
9
+ - nginx snippets for the relay, MCP, and site proxy;
10
+ - Prisma schema and migrations for Postgres-backed account, API key, passkey, device, and wallet state;
11
+ - PM2 and deploy helpers for the WIP-operated VPS.
12
+
13
+ WIP runs the production hosted relay so user setup is easy and works across networks. The source is public so users can inspect the relay path and build their own infrastructure.
14
+
15
+ For the self-hosting shape, read [docs/self-host.md](docs/self-host.md).
@@ -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 &copy; 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> &nbsp;|&nbsp; '
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 &copy; 2026 WIP Computer, Inc. All rights reserved. &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'
34
+ + '<a href="/legal/privacy/en-ww/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
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> &nbsp;|&nbsp; '
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
+ })();