@wipcomputer/wip-ldm-os 0.4.85-alpha.8 → 0.4.86

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 (57) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +137 -15
  3. package/bin/ldm.js +608 -75
  4. package/lib/deploy.mjs +90 -12
  5. package/lib/registry-migrations.mjs +296 -0
  6. package/package.json +21 -3
  7. package/scripts/test-boot-dir-truth.mjs +158 -0
  8. package/scripts/test-boot-hook-registration.mjs +136 -0
  9. package/scripts/test-boot-payload-trim.mjs +200 -0
  10. package/scripts/test-crc-agentid-tenant-boundary.mjs +2 -2
  11. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  12. package/scripts/test-crc-e2ee-session-route.mjs +9 -2
  13. package/scripts/test-crc-pair-login-flow.mjs +76 -1
  14. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  15. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  16. package/scripts/test-deploy-hook-ownership.mjs +160 -0
  17. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  18. package/scripts/test-install-prompt-policy.mjs +84 -0
  19. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  20. package/scripts/test-installer-target-self-update.mjs +131 -0
  21. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  22. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  23. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  24. package/scripts/test-ldm-status-concurrency.mjs +118 -0
  25. package/scripts/test-ldm-status-timeout.mjs +96 -0
  26. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  27. package/scripts/test-readme-install-prompt.mjs +66 -0
  28. package/shared/boot/boot-config.json +18 -8
  29. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  30. package/shared/rules/security.md +4 -0
  31. package/shared/templates/install-prompt.md +20 -2
  32. package/src/boot/README.md +24 -1
  33. package/src/boot/boot-config.json +18 -8
  34. package/src/boot/boot-hook.mjs +118 -28
  35. package/src/boot/installer.mjs +33 -10
  36. package/src/hosted-mcp/.env.example +4 -0
  37. package/src/hosted-mcp/README.md +37 -0
  38. package/src/hosted-mcp/app/footer.js +2 -2
  39. package/src/hosted-mcp/app/kaleidoscope-login.html +489 -42
  40. package/src/hosted-mcp/app/pair.html +21 -3
  41. package/src/hosted-mcp/app/wip-logo.png +0 -0
  42. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  43. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  44. package/src/hosted-mcp/demo/footer.js +2 -2
  45. package/src/hosted-mcp/demo/index.html +166 -44
  46. package/src/hosted-mcp/demo/login.html +87 -23
  47. package/src/hosted-mcp/demo/privacy.html +4 -214
  48. package/src/hosted-mcp/demo/tos.html +4 -189
  49. package/src/hosted-mcp/deploy.sh +2 -0
  50. package/src/hosted-mcp/docs/self-host.md +268 -0
  51. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  52. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  53. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  54. package/src/hosted-mcp/legal/legal.css +166 -0
  55. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  56. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  57. package/src/hosted-mcp/server.mjs +920 -74
@@ -74,7 +74,7 @@
74
74
  <!-- View 3: success -->
75
75
  <div id="success-view" style="display: none;">
76
76
  <h1>Paired.</h1>
77
- <div class="sub">Your laptop will pick this up in a few seconds.</div>
77
+ <div id="success-sub" class="sub">Your laptop will pick this up in a few seconds.</div>
78
78
  <div class="footer">You can close this tab.</div>
79
79
  </div>
80
80
  </div>
@@ -86,6 +86,7 @@ var PAIR_CODE_REGEX = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
86
86
 
87
87
  function getApiKey() { return sessionStorage.getItem('wip_api_key'); }
88
88
  function getHandle() { return sessionStorage.getItem('wip_handle') || '(unknown)'; }
89
+ function getPairPresenceToken() { return sessionStorage.getItem('wip_codex_pair_presence_token'); }
89
90
 
90
91
  function setStatus(elId, msg, kind) {
91
92
  var el = document.getElementById(elId);
@@ -104,7 +105,9 @@ function readCodeFromPath() {
104
105
  }
105
106
 
106
107
  // POST the code to /api/codex-relay/pair-complete using the api_key
107
- // from sessionStorage as Bearer token. On success, show the success view.
108
+ // from sessionStorage as Bearer token. Daemon key pairing also sends the
109
+ // short-lived token minted by the passkey verify step, so a stale ck- token
110
+ // alone cannot replace a daemon. On success, show the success view.
108
111
  async function submitPair(code, statusElId) {
109
112
  if (!PAIR_CODE_REGEX.test(code)) {
110
113
  setStatus(statusElId, 'That does not look like a valid code.', 'error');
@@ -118,7 +121,10 @@ async function submitPair(code, statusElId) {
118
121
  'Content-Type': 'application/json',
119
122
  'Authorization': 'Bearer ' + getApiKey(),
120
123
  },
121
- body: JSON.stringify({ code: code }),
124
+ body: JSON.stringify({
125
+ code: code,
126
+ codex_pair_presence_token: getPairPresenceToken(),
127
+ }),
122
128
  });
123
129
  var data = await res.json();
124
130
  if (!res.ok) {
@@ -127,9 +133,21 @@ async function submitPair(code, statusElId) {
127
133
  if (res.status === 410 || res.status === 404) {
128
134
  msg += ' Run codex-daemon link again on your laptop to get a fresh code.';
129
135
  }
136
+ if (res.status === 403 && data && data.error === 'fresh_presence_required') {
137
+ msg = 'Sign in again with your passkey to relink this daemon.';
138
+ sessionStorage.removeItem('wip_api_key');
139
+ sessionStorage.removeItem('wip_codex_pair_presence_token');
140
+ setTimeout(function() {
141
+ location.replace('/login?next=' + encodeURIComponent('/pair/' + code));
142
+ }, 1200);
143
+ }
130
144
  setStatus(statusElId, msg, 'error');
131
145
  return false;
132
146
  }
147
+ sessionStorage.removeItem('wip_codex_pair_presence_token');
148
+ document.getElementById('success-sub').textContent = data.replaced_daemon_key
149
+ ? 'Remote Control relinked this laptop. Existing browser control tabs need to reconnect.'
150
+ : 'Your laptop will pick this up in a few seconds.';
133
151
  document.getElementById('confirm-view').style.display = 'none';
134
152
  document.getElementById('manual-view').style.display = 'none';
135
153
  document.getElementById('success-view').style.display = 'block';
Binary file
@@ -0,0 +1,208 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ export function normalizeCodexCryptoVersions(versions) {
4
+ const out = Array.isArray(versions)
5
+ ? versions.filter((v) => typeof v === "string" && v.length > 0 && v.length <= 32).slice(0, 8)
6
+ : [];
7
+ return out.length ? out : ["e2ee-v1"];
8
+ }
9
+
10
+ export function codexDaemonPubkeyFingerprint(pubkey) {
11
+ if (typeof pubkey !== "string" || !pubkey) return null;
12
+ return "sha256:" + createHash("sha256").update(pubkey).digest("base64url").slice(0, 16);
13
+ }
14
+
15
+ export function evaluateCodexDaemonReconnectPubkey(existingKey, incomingPubkey) {
16
+ const existingPubkey = typeof existingKey?.pubkey === "string" && existingKey.pubkey ? existingKey.pubkey : null;
17
+ const nextPubkey = typeof incomingPubkey === "string" && incomingPubkey && incomingPubkey.length <= 1024 ? incomingPubkey : null;
18
+ const oldFingerprint = codexDaemonPubkeyFingerprint(existingPubkey);
19
+ const newFingerprint = codexDaemonPubkeyFingerprint(nextPubkey);
20
+
21
+ if (!nextPubkey) {
22
+ return {
23
+ allowed: false,
24
+ reason: "invalid_daemon_pubkey",
25
+ replaced: false,
26
+ old_fingerprint: oldFingerprint,
27
+ new_fingerprint: newFingerprint,
28
+ };
29
+ }
30
+ if (!existingPubkey || existingPubkey === nextPubkey) {
31
+ return {
32
+ allowed: true,
33
+ reason: null,
34
+ replaced: false,
35
+ old_fingerprint: oldFingerprint,
36
+ new_fingerprint: newFingerprint,
37
+ };
38
+ }
39
+ return {
40
+ allowed: false,
41
+ reason: "fresh_pair_required",
42
+ replaced: true,
43
+ old_fingerprint: oldFingerprint,
44
+ new_fingerprint: newFingerprint,
45
+ };
46
+ }
47
+
48
+ export function buildCodexBootstrapPayload({ identity, threadId, daemonOnline, daemonKey }) {
49
+ return {
50
+ handle: identity.handle,
51
+ thread_id: threadId,
52
+ daemon_online: daemonOnline,
53
+ daemon_public_key: daemonKey ? daemonKey.pubkey : null,
54
+ daemon_crypto_versions: daemonKey ? daemonKey.crypto_versions : null,
55
+ supported_crypto_versions: ["e2ee-v1"],
56
+ e2ee_available: !!daemonKey,
57
+ };
58
+ }
59
+
60
+ export function createCodexDaemonPubkeyRegistry({
61
+ usePrisma,
62
+ prisma,
63
+ devMode = false,
64
+ logger = console,
65
+ } = {}) {
66
+ const pubkeys = new Map();
67
+ const auditLog = [];
68
+
69
+ async function ensureStore() {
70
+ if (!usePrisma) return;
71
+ await prisma.$executeRawUnsafe(`
72
+ CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_keys (
73
+ tenant_id TEXT PRIMARY KEY,
74
+ pubkey TEXT NOT NULL,
75
+ crypto_versions_json TEXT NOT NULL,
76
+ registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
77
+ )
78
+ `);
79
+ }
80
+
81
+ async function ensureAuditStore() {
82
+ if (!usePrisma) return;
83
+ await prisma.$executeRawUnsafe(`
84
+ CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_key_audit (
85
+ id TEXT PRIMARY KEY,
86
+ tenant_id TEXT NOT NULL,
87
+ source TEXT NOT NULL,
88
+ old_pubkey_fingerprint TEXT,
89
+ new_pubkey_fingerprint TEXT,
90
+ replaced BOOLEAN NOT NULL,
91
+ registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
92
+ )
93
+ `);
94
+ }
95
+
96
+ async function loadFromDb() {
97
+ if (!usePrisma) return;
98
+ try {
99
+ await ensureStore();
100
+ const rows = await prisma.$queryRawUnsafe(`
101
+ SELECT tenant_id, pubkey, crypto_versions_json, registered_at
102
+ FROM codex_daemon_e2ee_keys
103
+ `);
104
+ for (const row of rows) {
105
+ let cryptoVersions = ["e2ee-v1"];
106
+ try { cryptoVersions = normalizeCodexCryptoVersions(JSON.parse(row.crypto_versions_json)); } catch {}
107
+ pubkeys.set(row.tenant_id, {
108
+ pubkey: row.pubkey,
109
+ crypto_versions: cryptoVersions,
110
+ registered_at: row.registered_at instanceof Date ? row.registered_at.toISOString() : String(row.registered_at),
111
+ });
112
+ }
113
+ logger.log("codex-relay: loaded " + rows.length + " persisted E2EE daemon pubkey(s)");
114
+ } catch (err) {
115
+ logger.error("codex-relay: failed to load persisted E2EE daemon pubkeys:", err.message);
116
+ if (!devMode) process.exit(1);
117
+ }
118
+ }
119
+
120
+ async function persist(agentId, pubkey, cryptoVersions) {
121
+ if (!usePrisma) return;
122
+ await ensureStore();
123
+ await prisma.$executeRawUnsafe(
124
+ `INSERT INTO codex_daemon_e2ee_keys
125
+ (tenant_id, pubkey, crypto_versions_json, registered_at)
126
+ VALUES ($1, $2, $3, now())
127
+ ON CONFLICT (tenant_id)
128
+ DO UPDATE SET
129
+ pubkey = EXCLUDED.pubkey,
130
+ crypto_versions_json = EXCLUDED.crypto_versions_json,
131
+ registered_at = EXCLUDED.registered_at`,
132
+ agentId,
133
+ pubkey,
134
+ JSON.stringify(cryptoVersions),
135
+ );
136
+ }
137
+
138
+ async function recordAudit(agentId, previousPubkey, nextPubkey, source, registeredAt) {
139
+ const oldFingerprint = codexDaemonPubkeyFingerprint(previousPubkey);
140
+ const newFingerprint = codexDaemonPubkeyFingerprint(nextPubkey);
141
+ const replaced = !!(previousPubkey && previousPubkey !== nextPubkey);
142
+ const entry = {
143
+ tenant_id: agentId,
144
+ source,
145
+ old_pubkey_fingerprint: oldFingerprint,
146
+ new_pubkey_fingerprint: newFingerprint,
147
+ replaced,
148
+ registered_at: registeredAt,
149
+ };
150
+ auditLog.push(entry);
151
+ if (!usePrisma) return;
152
+ await ensureAuditStore();
153
+ await prisma.$executeRawUnsafe(
154
+ `INSERT INTO codex_daemon_e2ee_key_audit
155
+ (id, tenant_id, source, old_pubkey_fingerprint, new_pubkey_fingerprint, replaced, registered_at)
156
+ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz)`,
157
+ randomUUID(),
158
+ agentId,
159
+ source,
160
+ oldFingerprint,
161
+ newFingerprint,
162
+ replaced,
163
+ registeredAt,
164
+ );
165
+ }
166
+
167
+ function register(agentId, pubkey, cryptoVersions, source) {
168
+ if (typeof agentId !== "string" || !agentId) return Promise.resolve(false);
169
+ if (typeof pubkey !== "string" || !pubkey || pubkey.length > 1024) return Promise.resolve(false);
170
+ const previous = pubkeys.get(agentId) || null;
171
+ const normalizedVersions = normalizeCodexCryptoVersions(cryptoVersions);
172
+ const registeredAt = new Date().toISOString();
173
+ pubkeys.set(agentId, {
174
+ pubkey,
175
+ crypto_versions: normalizedVersions,
176
+ registered_at: registeredAt,
177
+ });
178
+ logger.log("codex-relay: registered E2EE pubkey for " + agentId + " via " + source);
179
+ return persist(agentId, pubkey, normalizedVersions)
180
+ .then(() => recordAudit(agentId, previous?.pubkey || null, pubkey, source, registeredAt))
181
+ .then(() => ({
182
+ registered: true,
183
+ replaced: !!(previous?.pubkey && previous.pubkey !== pubkey),
184
+ old_fingerprint: codexDaemonPubkeyFingerprint(previous?.pubkey || null),
185
+ new_fingerprint: codexDaemonPubkeyFingerprint(pubkey),
186
+ }))
187
+ .catch((err) => {
188
+ logger.error("codex-relay: failed to persist E2EE pubkey for " + agentId + ":", err.message);
189
+ if (!devMode) throw err;
190
+ return false;
191
+ });
192
+ }
193
+
194
+ return {
195
+ pubkeys,
196
+ auditLog,
197
+ ensureStore,
198
+ ensureAuditStore,
199
+ loadFromDb,
200
+ register,
201
+ get(agentId) {
202
+ return pubkeys.get(agentId) || null;
203
+ },
204
+ clearMemoryForTest() {
205
+ pubkeys.clear();
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,140 @@
1
+ const DEFAULTS = {
2
+ maxFrameBytes: 256 * 1024,
3
+ rateWindowMs: 10_000,
4
+ maxMessagesPerWindow: 120,
5
+ maxBytesPerWindow: 1024 * 1024,
6
+ maxBrowserSocketsPerThread: 8,
7
+ idleTtlMs: 30 * 60 * 1000,
8
+ maxMalformedFrames: 3,
9
+ maxPendingBytes: 1024 * 1024,
10
+ };
11
+
12
+ export const CODEX_WS_CLOSE_CODES = Object.freeze({
13
+ oversizedFrame: 4400,
14
+ rateLimited: 4401,
15
+ tooManySockets: 4402,
16
+ idleTimeout: 4403,
17
+ malformedFrames: 4404,
18
+ operatorDisabled: 4405,
19
+ pendingBytes: 4406,
20
+ });
21
+
22
+ function positiveInt(value, fallback) {
23
+ const parsed = Number.parseInt(String(value ?? ""), 10);
24
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
25
+ }
26
+
27
+ function parseAgentSet(value) {
28
+ if (!value) return new Set();
29
+ return new Set(
30
+ String(value)
31
+ .split(",")
32
+ .map((part) => part.trim())
33
+ .filter(Boolean),
34
+ );
35
+ }
36
+
37
+ export function createCodexWsAbuseLimitConfig(env = process.env) {
38
+ return {
39
+ maxFrameBytes: positiveInt(env.LDM_CODEX_WS_MAX_FRAME_BYTES, DEFAULTS.maxFrameBytes),
40
+ rateWindowMs: positiveInt(env.LDM_CODEX_WS_RATE_WINDOW_MS, DEFAULTS.rateWindowMs),
41
+ maxMessagesPerWindow: positiveInt(env.LDM_CODEX_WS_MAX_MESSAGES_PER_WINDOW, DEFAULTS.maxMessagesPerWindow),
42
+ maxBytesPerWindow: positiveInt(env.LDM_CODEX_WS_MAX_BYTES_PER_WINDOW, DEFAULTS.maxBytesPerWindow),
43
+ maxBrowserSocketsPerThread: positiveInt(
44
+ env.LDM_CODEX_WS_MAX_BROWSER_SOCKETS_PER_THREAD,
45
+ DEFAULTS.maxBrowserSocketsPerThread,
46
+ ),
47
+ idleTtlMs: positiveInt(env.LDM_CODEX_WS_IDLE_TTL_MS, DEFAULTS.idleTtlMs),
48
+ maxMalformedFrames: positiveInt(env.LDM_CODEX_WS_MAX_MALFORMED_FRAMES, DEFAULTS.maxMalformedFrames),
49
+ maxPendingBytes: positiveInt(env.LDM_CODEX_WS_MAX_PENDING_BYTES, DEFAULTS.maxPendingBytes),
50
+ killSwitchAll: env.LDM_CODEX_WS_KILL_SWITCH_ALL === "1",
51
+ killSwitchAgents: parseAgentSet(env.LDM_CODEX_WS_KILL_SWITCH_AGENTS),
52
+ };
53
+ }
54
+
55
+ export function isCodexWsAgentDisabled(config, agentId) {
56
+ return !!(
57
+ config?.killSwitchAll
58
+ || (typeof agentId === "string" && config?.killSwitchAgents?.has(agentId))
59
+ );
60
+ }
61
+
62
+ function rejected(code, reason) {
63
+ return { ok: false, code, reason };
64
+ }
65
+
66
+ export function createCodexWsConnectionGuard({ config, agentId, now = Date.now }) {
67
+ let windowStartMs = now();
68
+ let messagesInWindow = 0;
69
+ let bytesInWindow = 0;
70
+ let malformedFrames = 0;
71
+ let lastActivityMs = windowStartMs;
72
+
73
+ function resetWindow(nowMs) {
74
+ windowStartMs = nowMs;
75
+ messagesInWindow = 0;
76
+ bytesInWindow = 0;
77
+ }
78
+
79
+ return {
80
+ observeFrame(byteLength, nowMs = now()) {
81
+ if (isCodexWsAgentDisabled(config, agentId)) {
82
+ return rejected(CODEX_WS_CLOSE_CODES.operatorDisabled, "operator disabled");
83
+ }
84
+ if (byteLength > config.maxFrameBytes) {
85
+ return rejected(CODEX_WS_CLOSE_CODES.oversizedFrame, "frame too large");
86
+ }
87
+ if (nowMs - windowStartMs > config.rateWindowMs) {
88
+ resetWindow(nowMs);
89
+ }
90
+ messagesInWindow += 1;
91
+ bytesInWindow += byteLength;
92
+ lastActivityMs = nowMs;
93
+ if (messagesInWindow > config.maxMessagesPerWindow) {
94
+ return rejected(CODEX_WS_CLOSE_CODES.rateLimited, "message rate limit");
95
+ }
96
+ if (bytesInWindow > config.maxBytesPerWindow) {
97
+ return rejected(CODEX_WS_CLOSE_CODES.rateLimited, "byte rate limit");
98
+ }
99
+ return { ok: true };
100
+ },
101
+ observeMalformed(nowMs = now()) {
102
+ lastActivityMs = nowMs;
103
+ malformedFrames += 1;
104
+ if (malformedFrames > config.maxMalformedFrames) {
105
+ return rejected(CODEX_WS_CLOSE_CODES.malformedFrames, "malformed frame limit");
106
+ }
107
+ return { ok: true };
108
+ },
109
+ observePendingBytes(bufferedAmount) {
110
+ if (bufferedAmount > config.maxPendingBytes) {
111
+ return rejected(CODEX_WS_CLOSE_CODES.pendingBytes, "pending byte limit");
112
+ }
113
+ return { ok: true };
114
+ },
115
+ observeIdle(nowMs = now()) {
116
+ if (nowMs - lastActivityMs > config.idleTtlMs) {
117
+ return rejected(CODEX_WS_CLOSE_CODES.idleTimeout, "idle timeout");
118
+ }
119
+ return { ok: true };
120
+ },
121
+ };
122
+ }
123
+
124
+ export function codexWsFrameByteLength(data) {
125
+ if (typeof data === "string") return Buffer.byteLength(data);
126
+ if (Buffer.isBuffer(data)) return data.length;
127
+ if (data instanceof ArrayBuffer) return data.byteLength;
128
+ if (ArrayBuffer.isView(data)) return data.byteLength;
129
+ return Buffer.byteLength(String(data ?? ""));
130
+ }
131
+
132
+ export function formatCodexWsLimitLog({ agentId, threadId, connectionId, reason }) {
133
+ return (
134
+ "codex-relay: websocket limit"
135
+ + " reason=" + String(reason || "unknown").slice(0, 64)
136
+ + " agent=" + String(agentId || "<none>").slice(0, 96)
137
+ + " thread=" + String(threadId || "<none>").slice(0, 96)
138
+ + " conn=" + String(connectionId || "<none>").slice(0, 64)
139
+ );
140
+ }
@@ -22,7 +22,7 @@
22
22
  + '<p style="margin:2px 0 0;">Learning Dreaming Machines</p>'
23
23
  + '<p style="margin:8px 0 0;">Copyright &copy; 2026 WIP Computer, Inc. All rights reserved.</p>'
24
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; '
25
+ + '<a href="/legal/privacy/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
26
26
  + '<a href="/legal/internet-services/terms/site.html" style="color:#a8a4a0;text-decoration:none;">Terms of Use</a></p>'
27
27
  + '<p style="margin:4px 0 0;">'
28
28
  + '<a href="/agent.txt" style="color:#a8a4a0;text-decoration:none;">Are you an AI Agent?</a></p>'
@@ -31,7 +31,7 @@
31
31
  inner.innerHTML = '<p style="margin:0;">WIP Computer, Inc.</p>'
32
32
  + '<p style="margin:2px 0 0;">Learning Dreaming Machines</p>'
33
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; '
34
+ + '<a href="/legal/privacy/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
35
35
  + '<a href="/legal/internet-services/terms/site.html" style="color:#a8a4a0;text-decoration:none;">Terms of Use</a></p>'
36
36
  + '<p style="margin:4px 0 0;">'
37
37
  + '<a href="/agent.txt" style="color:#a8a4a0;text-decoration:none;">Are you an AI Agent?</a> &nbsp;|&nbsp; '