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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ldm.js CHANGED
@@ -1537,13 +1537,21 @@ async function cmdInstall() {
1537
1537
  // npm install --prefix silently fails for scoped packages in temp directories...
1538
1538
  // it creates the lock file but doesn't extract files. npm pack is reliable.
1539
1539
  const npmName = resolvedTarget;
1540
+ // --alpha and --beta select the corresponding npm dist-tag instead of @latest.
1541
+ // Without this, `ldm install --alpha <pkg>` was pulling the @latest version
1542
+ // from npm pack and an existing global install would never advance to the
1543
+ // current alpha. Now the tag flows through pack + the downstream
1544
+ // installSingleTool's `npm install -g <pkg>@<version>` step uses the
1545
+ // version baked into the alpha tarball.
1546
+ const npmTag = ALPHA_FLAG ? 'alpha' : (BETA_FLAG ? 'beta' : '');
1547
+ const packTarget = npmTag ? `${npmName}@${npmTag}` : npmName;
1540
1548
  const tempDir = join(LDM_TMP, `npm-${Date.now()}`);
1541
1549
  console.log('');
1542
- console.log(` Installing ${npmName} from npm...`);
1550
+ console.log(` Installing ${packTarget} from npm...`);
1543
1551
  try {
1544
1552
  mkdirSync(tempDir, { recursive: true });
1545
1553
  // Use npm pack + tar instead of npm install --prefix
1546
- const tarball = execSync(`npm pack ${npmName} --pack-destination "${tempDir}" 2>/dev/null`, {
1554
+ const tarball = execSync(`npm pack ${packTarget} --pack-destination "${tempDir}" 2>/dev/null`, {
1547
1555
  encoding: 'utf8', timeout: 60000, cwd: tempDir,
1548
1556
  }).trim();
1549
1557
  const tarPath = join(tempDir, tarball);
@@ -4289,6 +4297,164 @@ async function main() {
4289
4297
  console.log('');
4290
4298
  }
4291
4299
 
4300
+ // ── ldm uninstall <pkg> ──
4301
+ //
4302
+ // Removes a single LDM-installed package. Used to reset a single
4303
+ // extension between dogfood cycles without taking down the rest of
4304
+ // LDM OS. Pairs with the per-package cleanup hook each package may
4305
+ // ship (e.g. `codex-daemon uninstall --purge` for Codex Remote
4306
+ // Control), which the user runs first to clean up product-specific
4307
+ // state. This command then removes the LDM-side install record + the
4308
+ // global npm package + the LDM extension dir.
4309
+ //
4310
+ // Safety:
4311
+ // - Never touches ~/.codex/ or other unrelated user state.
4312
+ // - Never removes ~/.ldm/memory/ or ~/.ldm/agents/.
4313
+ // - Never removes other extensions.
4314
+ // - Idempotent: running twice exits cleanly.
4315
+ // - Refuses to uninstall LDM OS itself (use `ldm uninstall` for that).
4316
+
4317
+ async function cmdUninstallPackage(pkgName) {
4318
+ const isDryRun = args.includes('--dry-run');
4319
+
4320
+ if (pkgName === 'wip-ldm-os' || pkgName === '@wipcomputer/wip-ldm-os') {
4321
+ console.error(' Refusing to uninstall LDM OS itself with `ldm uninstall <pkg>`.');
4322
+ console.error(' To remove all of LDM OS: ldm uninstall');
4323
+ process.exit(1);
4324
+ }
4325
+
4326
+ console.log('');
4327
+ console.log(` ldm uninstall ${pkgName}`);
4328
+ console.log(' ────────────────────────────────────');
4329
+
4330
+ // 1. Look up the package in the registry.
4331
+ const registryPath = join(LDM_EXTENSIONS, 'registry.json');
4332
+ let registry = { _format: 'v1', extensions: {} };
4333
+ try {
4334
+ if (existsSync(registryPath)) {
4335
+ registry = JSON.parse(readFileSync(registryPath, 'utf8'));
4336
+ }
4337
+ } catch (e) {
4338
+ console.error(` ! could not read registry at ${registryPath}: ${e.message}`);
4339
+ }
4340
+ const entry = registry.extensions?.[pkgName] || null;
4341
+
4342
+ // 2. Resolve npm package name.
4343
+ // Registry entries from npm installs put the npm name in `source.npm`
4344
+ // or in the top-level `name` field. Fall back to the user-supplied
4345
+ // pkgName (works for unscoped packages).
4346
+ const npmPkg = entry?.source?.npm || entry?.name || pkgName;
4347
+
4348
+ // 3. Resolve LDM extension dir(s).
4349
+ const ldmExtPath = entry?.paths?.ldm
4350
+ || entry?.ldmPath
4351
+ || join(LDM_EXTENSIONS, pkgName);
4352
+ const ocExtPath = entry?.paths?.openclaw
4353
+ || entry?.ocPath
4354
+ || null;
4355
+
4356
+ // 4. Build the action plan.
4357
+ const actions = [];
4358
+ let pkgInstalledGlobally = false;
4359
+ try {
4360
+ const npmList = execSync(`npm list -g --depth=0 --json 2>/dev/null`, { encoding: 'utf8' });
4361
+ const deps = JSON.parse(npmList).dependencies || {};
4362
+ pkgInstalledGlobally = !!deps[npmPkg];
4363
+ } catch {}
4364
+
4365
+ if (pkgInstalledGlobally) {
4366
+ actions.push({ kind: 'npm-uninstall', npmPkg });
4367
+ } else {
4368
+ actions.push({ kind: 'skip', label: `npm: ${npmPkg} not installed globally` });
4369
+ }
4370
+ if (existsSync(ldmExtPath)) {
4371
+ actions.push({ kind: 'rm-dir', label: 'LDM extension dir', path: ldmExtPath });
4372
+ } else {
4373
+ actions.push({ kind: 'skip', label: `LDM extension dir: ${ldmExtPath} already gone` });
4374
+ }
4375
+ if (ocExtPath && existsSync(ocExtPath)) {
4376
+ actions.push({ kind: 'rm-dir', label: 'OpenClaw extension dir', path: ocExtPath });
4377
+ }
4378
+ if (entry) {
4379
+ actions.push({ kind: 'registry-remove', name: pkgName });
4380
+ } else {
4381
+ actions.push({ kind: 'skip', label: `registry: no entry for ${pkgName}` });
4382
+ }
4383
+
4384
+ const realActions = actions.filter(a => a.kind !== 'skip');
4385
+ const skips = actions.filter(a => a.kind === 'skip');
4386
+
4387
+ console.log('');
4388
+ if (realActions.length === 0) {
4389
+ for (const s of skips) console.log(` - ${s.label}`);
4390
+ console.log('');
4391
+ console.log(' Nothing to do.');
4392
+ console.log('');
4393
+ console.log(' Will NOT touch ~/.codex/ or ~/.ldm/memory/ or other extensions.');
4394
+ return;
4395
+ }
4396
+ console.log(' Will:');
4397
+ for (const a of realActions) {
4398
+ switch (a.kind) {
4399
+ case 'npm-uninstall':
4400
+ console.log(` - npm uninstall -g ${a.npmPkg}`);
4401
+ break;
4402
+ case 'rm-dir':
4403
+ console.log(` - remove ${a.label}: ${a.path}`);
4404
+ break;
4405
+ case 'registry-remove':
4406
+ console.log(` - remove registry entry for ${a.name}`);
4407
+ break;
4408
+ }
4409
+ }
4410
+ for (const s of skips) console.log(` - skipped: ${s.label}`);
4411
+ console.log('');
4412
+ console.log(' Will NOT touch ~/.codex/ or ~/.ldm/memory/ or other extensions.');
4413
+
4414
+ if (isDryRun) {
4415
+ console.log('');
4416
+ console.log(' Dry run. Nothing removed.');
4417
+ console.log('');
4418
+ console.log(' Re-run without --dry-run to apply.');
4419
+ return;
4420
+ }
4421
+
4422
+ console.log('');
4423
+ for (const a of realActions) {
4424
+ switch (a.kind) {
4425
+ case 'npm-uninstall':
4426
+ try {
4427
+ execSync(`npm uninstall -g ${a.npmPkg}`, { stdio: 'pipe', timeout: 60000 });
4428
+ console.log(` + npm uninstall -g ${a.npmPkg}`);
4429
+ } catch (e) {
4430
+ console.error(` ! npm uninstall -g ${a.npmPkg} failed: ${e.message}`);
4431
+ }
4432
+ break;
4433
+ case 'rm-dir':
4434
+ try {
4435
+ execSync(`rm -rf "${a.path}"`, { stdio: 'pipe' });
4436
+ console.log(` + removed ${a.label}: ${a.path}`);
4437
+ } catch (e) {
4438
+ console.error(` ! could not remove ${a.path}: ${e.message}`);
4439
+ }
4440
+ break;
4441
+ case 'registry-remove':
4442
+ try {
4443
+ delete registry.extensions[a.name];
4444
+ writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
4445
+ console.log(` + removed registry entry for ${a.name}`);
4446
+ } catch (e) {
4447
+ console.error(` ! could not update registry: ${e.message}`);
4448
+ }
4449
+ break;
4450
+ }
4451
+ }
4452
+
4453
+ console.log('');
4454
+ console.log(' Uninstalled.');
4455
+ console.log('');
4456
+ }
4457
+
4292
4458
  // ── ldm worktree ──
4293
4459
 
4294
4460
  async function cmdWorktree() {
@@ -4619,9 +4785,17 @@ async function main() {
4619
4785
  case 'disable':
4620
4786
  await cmdDisable();
4621
4787
  break;
4622
- case 'uninstall':
4623
- await cmdUninstall();
4788
+ case 'uninstall': {
4789
+ // ldm uninstall <pkg> [--dry-run] removes one package
4790
+ // ldm uninstall removes the whole LDM OS install
4791
+ const target = args.slice(1).find(a => !a.startsWith('--'));
4792
+ if (target) {
4793
+ await cmdUninstallPackage(target);
4794
+ } else {
4795
+ await cmdUninstall();
4796
+ }
4624
4797
  break;
4798
+ }
4625
4799
  case 'worktree':
4626
4800
  await cmdWorktree();
4627
4801
  break;
package/lib/deploy.mjs CHANGED
@@ -1097,18 +1097,18 @@ function registerMCP(repoPath, door, toolName) {
1097
1097
  *
1098
1098
  * Returns true if at least one door installed successfully.
1099
1099
  */
1100
- function installClaudeCodeHook(repoPath, doorOrDoors) {
1100
+ function installClaudeCodeHook(repoPath, doorOrDoors, toolName = basename(repoPath)) {
1101
1101
  const doors = Array.isArray(doorOrDoors) ? doorOrDoors : [doorOrDoors];
1102
1102
  let anyOk = false;
1103
1103
  for (const door of doors) {
1104
- if (installClaudeCodeHookEvent(repoPath, door)) {
1104
+ if (installClaudeCodeHookEvent(repoPath, door, toolName)) {
1105
1105
  anyOk = true;
1106
1106
  }
1107
1107
  }
1108
1108
  return anyOk;
1109
1109
  }
1110
1110
 
1111
- function installClaudeCodeHookEvent(repoPath, door) {
1111
+ function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath)) {
1112
1112
  const settingsPath = join(HOME, '.claude', 'settings.json');
1113
1113
  let settings = readJSON(settingsPath);
1114
1114
 
@@ -1117,7 +1117,6 @@ function installClaudeCodeHookEvent(repoPath, door) {
1117
1117
  return false;
1118
1118
  }
1119
1119
 
1120
- const toolName = basename(repoPath);
1121
1120
  const extDir = join(LDM_EXTENSIONS, toolName);
1122
1121
  const installedGuard = join(extDir, 'guard.mjs');
1123
1122
 
@@ -1422,7 +1421,7 @@ export function installSingleTool(toolPath) {
1422
1421
 
1423
1422
  if (interfaces.claudeCodeHook) {
1424
1423
  if (isEnabled || isAlreadyDeployed) {
1425
- if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook)) installed++;
1424
+ if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook, toolName)) installed++;
1426
1425
  } else {
1427
1426
  skip(`Hook: ${toolName} not enabled`);
1428
1427
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.84",
3
+ "version": "0.4.85-alpha.2",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -22,6 +22,7 @@
22
22
  "validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
23
23
  "test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
24
24
  "test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
25
+ "test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
25
26
  "test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
26
27
  "test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
27
28
  "test:bin-manifest": "node scripts/test-bin-manifest.mjs",
@@ -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,109 @@
1
+ # Codex Remote Control relay
2
+ #
3
+ # Routes /api/codex-relay/* to the wip-mcp Node app at 127.0.0.1:18800.
4
+ # Without these blocks nginx falls back to /index.html and the phone-side
5
+ # bootstrap + ws-ticket calls receive HTML, which breaks the E2EE handshake
6
+ # and the relay attach flow.
7
+ #
8
+ # Owners: this snippet pairs with src/hosted-mcp/server.mjs codex-relay
9
+ # routes and the kaleidoscope-private phone surface. See
10
+ # wip-ldm-os-private/ai/product/plans-prds/codex-remote-control/
11
+ # for the full architecture.
12
+ #
13
+ # Include from inside the wip.computer server block.
14
+
15
+ # ── HTTP routes ──────────────────────────────────────────────────────
16
+ # bootstrap (GET) ... phone reads daemon E2EE pubkey + presence
17
+ location /api/codex-relay/bootstrap/ {
18
+ proxy_pass http://127.0.0.1:18800;
19
+ proxy_http_version 1.1;
20
+ proxy_set_header Host $host;
21
+ proxy_set_header X-Real-IP $remote_addr;
22
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
23
+ proxy_set_header X-Forwarded-Proto $scheme;
24
+ }
25
+
26
+ # ws-ticket (POST) ... single-use, route-bound, 60s TTL
27
+ location /api/codex-relay/ws-ticket {
28
+ proxy_pass http://127.0.0.1:18800;
29
+ proxy_http_version 1.1;
30
+ proxy_set_header Host $host;
31
+ proxy_set_header X-Real-IP $remote_addr;
32
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
33
+ proxy_set_header X-Forwarded-Proto $scheme;
34
+ }
35
+
36
+ # state (GET) ... daemon presence diagnostic
37
+ location /api/codex-relay/state {
38
+ proxy_pass http://127.0.0.1:18800;
39
+ proxy_http_version 1.1;
40
+ proxy_set_header Host $host;
41
+ proxy_set_header X-Real-IP $remote_addr;
42
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
43
+ proxy_set_header X-Forwarded-Proto $scheme;
44
+ }
45
+
46
+ # pair-init (POST) ... codex-daemon link starts here
47
+ location /api/codex-relay/pair-init {
48
+ proxy_pass http://127.0.0.1:18800;
49
+ proxy_http_version 1.1;
50
+ proxy_set_header Host $host;
51
+ proxy_set_header X-Real-IP $remote_addr;
52
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
53
+ proxy_set_header X-Forwarded-Proto $scheme;
54
+ }
55
+
56
+ # pair-status (GET) ... daemon polls during pair flow
57
+ location /api/codex-relay/pair-status/ {
58
+ proxy_pass http://127.0.0.1:18800;
59
+ proxy_http_version 1.1;
60
+ proxy_set_header Host $host;
61
+ proxy_set_header X-Real-IP $remote_addr;
62
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
63
+ proxy_set_header X-Forwarded-Proto $scheme;
64
+ }
65
+
66
+ # pair-complete (POST) ... phone -> server after passkey + code
67
+ location /api/codex-relay/pair-complete {
68
+ proxy_pass http://127.0.0.1:18800;
69
+ proxy_http_version 1.1;
70
+ proxy_set_header Host $host;
71
+ proxy_set_header X-Real-IP $remote_addr;
72
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
73
+ proxy_set_header X-Forwarded-Proto $scheme;
74
+ }
75
+
76
+ # ── WebSocket routes ────────────────────────────────────────────────
77
+ # Standard nginx WebSocket pattern: Upgrade + Connection "upgrade", long
78
+ # read/send timeout for daemon's persistent socket, buffering off so
79
+ # streamed Codex events don't pool.
80
+
81
+ # Phone side ... attached with ?ticket=<single-use>
82
+ location /api/codex-relay/web/ {
83
+ proxy_pass http://127.0.0.1:18800;
84
+ proxy_http_version 1.1;
85
+ proxy_set_header Upgrade $http_upgrade;
86
+ proxy_set_header Connection "upgrade";
87
+ proxy_set_header Host $host;
88
+ proxy_set_header X-Real-IP $remote_addr;
89
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
90
+ proxy_set_header X-Forwarded-Proto $scheme;
91
+ proxy_read_timeout 86400;
92
+ proxy_send_timeout 86400;
93
+ proxy_buffering off;
94
+ }
95
+
96
+ # Daemon side ... long-lived presence socket from the user's Mac
97
+ location /api/codex-relay/daemon {
98
+ proxy_pass http://127.0.0.1:18800;
99
+ proxy_http_version 1.1;
100
+ proxy_set_header Upgrade $http_upgrade;
101
+ proxy_set_header Connection "upgrade";
102
+ proxy_set_header Host $host;
103
+ proxy_set_header X-Real-IP $remote_addr;
104
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
105
+ proxy_set_header X-Forwarded-Proto $scheme;
106
+ proxy_read_timeout 86400;
107
+ proxy_send_timeout 86400;
108
+ proxy_buffering off;
109
+ }
@@ -7,103 +7,77 @@ server {
7
7
  access_log /var/log/nginx/wip.computer.access.log;
8
8
  error_log /var/log/nginx/wip.computer.error.log;
9
9
 
10
- location / {
11
- autoindex off;
12
- try_files $uri $uri/ /index.html;
10
+ # Docs redirect to docs.wip.computer
11
+ location = /doc {
12
+ return 301 https://docs.wip.computer;
13
13
  }
14
-
15
- add_header X-Frame-Options "SAMEORIGIN" always;
16
- add_header X-Content-Type-Options "nosniff" always;
17
- add_header X-XSS-Protection "1; mode=block" always;
18
-
19
- # ── Codex Remote Control relay ──
20
- # The Node app at 127.0.0.1:18800 (wip-mcp/server.mjs) owns these.
21
- # Without these blocks, nginx falls back to /index.html and the
22
- # phone-side bootstrap + ws-ticket calls receive HTML, which breaks
23
- # E2EE handshake and relay attach. See:
24
- # wip-ldm-os-private/ai/product/plans-prds/codex-remote-control/
25
-
26
- # HTTP routes: bootstrap (GET), ws-ticket (POST), state (GET),
27
- # pair-init (POST), pair-status (GET), pair-complete (POST).
28
- location /api/codex-relay/bootstrap/ {
29
- proxy_pass http://127.0.0.1:18800;
30
- proxy_http_version 1.1;
31
- proxy_set_header Host $host;
32
- proxy_set_header X-Real-IP $remote_addr;
33
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
34
- proxy_set_header X-Forwarded-Proto $scheme;
14
+ location = /docs {
15
+ return 301 https://docs.wip.computer;
35
16
  }
36
- location /api/codex-relay/ws-ticket {
37
- proxy_pass http://127.0.0.1:18800;
38
- proxy_http_version 1.1;
39
- proxy_set_header Host $host;
40
- proxy_set_header X-Real-IP $remote_addr;
41
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
42
- proxy_set_header X-Forwarded-Proto $scheme;
43
- }
44
- location /api/codex-relay/state {
45
- proxy_pass http://127.0.0.1:18800;
46
- proxy_http_version 1.1;
47
- proxy_set_header Host $host;
48
- proxy_set_header X-Real-IP $remote_addr;
49
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
50
- proxy_set_header X-Forwarded-Proto $scheme;
17
+ location /docs/ {
18
+ return 301 https://docs.wip.computer;
51
19
  }
52
- location /api/codex-relay/pair-init {
53
- proxy_pass http://127.0.0.1:18800;
20
+
21
+ # MCP server
22
+ # OAuth 2.0 for Claude iOS connector
23
+ include snippets/mcp-oauth.conf;
24
+ include snippets/mcp-server.conf;
25
+
26
+ # Codex Remote Control relay (HTTP + WSS proxy_pass to wip-mcp Node app
27
+ # at 127.0.0.1:18800; covers /api/codex-relay/bootstrap, ws-ticket, state,
28
+ # pair-init/status/complete, web/<tid>, daemon).
29
+ include snippets/codex-relay.conf;
30
+
31
+ # Codex Remote Control phone surface
32
+ # The Next.js app at kaleidoscope.wip.computer (port 3001) renders
33
+ # /codex-remote-control/[threadId]. The MCP tool's auth URL uses
34
+ # wip.computer as the origin, so wip.computer/codex-remote-control/<tid>
35
+ # must reach the same Next.js app. WebSocket Upgrade headers included
36
+ # so live-reload / RSC streams work cleanly.
37
+ location /codex-remote-control/ {
38
+ proxy_pass http://127.0.0.1:3001;
54
39
  proxy_http_version 1.1;
40
+ proxy_set_header Upgrade $http_upgrade;
41
+ proxy_set_header Connection "upgrade";
55
42
  proxy_set_header Host $host;
56
43
  proxy_set_header X-Real-IP $remote_addr;
57
44
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
58
45
  proxy_set_header X-Forwarded-Proto $scheme;
59
46
  }
60
- location /api/codex-relay/pair-status/ {
61
- proxy_pass http://127.0.0.1:18800;
47
+
48
+ # Health check
49
+ location /health {
50
+ proxy_pass http://127.0.0.1:18800/health;
62
51
  proxy_http_version 1.1;
63
- proxy_set_header Host $host;
64
- proxy_set_header X-Real-IP $remote_addr;
65
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
66
- proxy_set_header X-Forwarded-Proto $scheme;
67
52
  }
68
- location /api/codex-relay/pair-complete {
69
- proxy_pass http://127.0.0.1:18800;
70
- proxy_http_version 1.1;
71
- proxy_set_header Host $host;
72
- proxy_set_header X-Real-IP $remote_addr;
73
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
74
- proxy_set_header X-Forwarded-Proto $scheme;
53
+
54
+
55
+ # Demo pages (static files)
56
+ location /demo {
57
+ alias /var/www/wip.computer/app/mcp-server/demo;
58
+ index index.html;
59
+ try_files $uri $uri/ $uri/index.html =404;
75
60
  }
76
61
 
77
- # WebSocket routes: phone (web) and daemon long-lived sockets.
78
- # Upgrade headers are the standard WebSocket-via-nginx pattern.
79
- # Long read timeout because daemon sockets stay open indefinitely.
80
- location /api/codex-relay/web/ {
81
- proxy_pass http://127.0.0.1:18800;
62
+ # Demo API (proxied to MCP server)
63
+ location /demo/api/ {
64
+ proxy_pass http://127.0.0.1:18800/demo/api/;
82
65
  proxy_http_version 1.1;
83
- proxy_set_header Upgrade $http_upgrade;
84
- proxy_set_header Connection "upgrade";
85
66
  proxy_set_header Host $host;
86
67
  proxy_set_header X-Real-IP $remote_addr;
87
68
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
88
69
  proxy_set_header X-Forwarded-Proto $scheme;
89
- proxy_read_timeout 86400;
90
- proxy_send_timeout 86400;
91
- proxy_buffering off;
92
70
  }
93
- location /api/codex-relay/daemon {
94
- proxy_pass http://127.0.0.1:18800;
95
- proxy_http_version 1.1;
96
- proxy_set_header Upgrade $http_upgrade;
97
- proxy_set_header Connection "upgrade";
98
- proxy_set_header Host $host;
99
- proxy_set_header X-Real-IP $remote_addr;
100
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
101
- proxy_set_header X-Forwarded-Proto $scheme;
102
- proxy_read_timeout 86400;
103
- proxy_send_timeout 86400;
104
- proxy_buffering off;
71
+
72
+ location / {
73
+ autoindex off;
74
+ try_files $uri $uri/ /index.html;
105
75
  }
106
76
 
77
+ add_header X-Frame-Options "SAMEORIGIN" always;
78
+ add_header X-Content-Type-Options "nosniff" always;
79
+ add_header X-XSS-Protection "1; mode=block" always;
80
+
107
81
  location ~ /\. {
108
82
  deny all;
109
83
  }
@@ -1,254 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
- <title>Codex remote control</title>
7
- <style>
8
- *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
9
- :root {
10
- --bg: #FFFDF5;
11
- --bg-event: #F5F3ED;
12
- --bg-tool: #F0EDE6;
13
- --text: #1a1a1a;
14
- --text-muted: #8a8580;
15
- --accent: #0033FF;
16
- --danger: #b00020;
17
- --border: #E0DDD6;
18
- --user-bubble: #E8F0FE;
19
- --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
20
- --mono: ui-monospace, "SF Mono", Menlo, monospace;
21
- }
22
- html, body { height: 100%; font-family: var(--font); background: var(--bg); color: var(--text); }
23
- body { display: flex; flex-direction: column; }
24
- header { padding: 12px 16px; padding-top: calc(12px + env(safe-area-inset-top, 0px)); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
25
- header .id { flex: 1; font-size: 13px; color: var(--text-muted); font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
26
- header .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
27
- header .dot.online { background: #2ea44f; }
28
- header .dot.offline { background: var(--danger); }
29
- main { flex: 1; overflow-y: auto; padding: 16px; padding-bottom: 0; -webkit-overflow-scrolling: touch; }
30
- .event { margin-bottom: 12px; padding: 12px 14px; border-radius: 10px; background: var(--bg-event); font-size: 14px; line-height: 1.45; }
31
- .event .meta { font-size: 11px; color: var(--text-muted); font-family: var(--mono); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
32
- .event.user { background: var(--user-bubble); }
33
- .event.agent_message { background: var(--bg); border: 1px solid var(--border); }
34
- .event.command_execution { background: var(--bg-tool); font-family: var(--mono); white-space: pre-wrap; word-break: break-all; }
35
- .event.command_execution.failed { border-left: 3px solid var(--danger); }
36
- .event.error { background: #fff0f0; border: 1px solid #f0c0c0; color: var(--danger); }
37
- .event.system { background: transparent; color: var(--text-muted); font-size: 12px; padding: 6px 0; text-align: center; }
38
- .event pre { font-family: var(--mono); white-space: pre-wrap; word-break: break-word; font-size: 13px; }
39
- footer { padding: 12px; padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--border); background: var(--bg); }
40
- .composer { display: flex; gap: 8px; align-items: flex-end; }
41
- textarea {
42
- flex: 1; min-height: 44px; max-height: 120px; padding: 12px;
43
- border: 1px solid var(--border); border-radius: 10px;
44
- background: var(--bg); color: var(--text); font-family: var(--font); font-size: 16px;
45
- resize: none;
46
- }
47
- textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
48
- button { padding: 12px 16px; border: none; border-radius: 10px; font-family: var(--font); font-size: 14px; font-weight: 600; cursor: pointer; -webkit-tap-highlight-color: transparent; }
49
- button:active { transform: scale(0.97); }
50
- button:disabled { opacity: 0.4; cursor: not-allowed; }
51
- .btn-send { background: var(--accent); color: white; }
52
- .btn-stop { background: var(--danger); color: white; }
53
- </style>
54
- </head>
55
- <body>
56
- <header>
57
- <div class="dot" id="presence" title="connecting"></div>
58
- <div class="id" id="threadId">...</div>
59
- <button id="stopBtn" class="btn-stop" type="button" disabled>Stop</button>
60
- </header>
61
- <main id="log"></main>
62
- <footer>
63
- <form class="composer" id="composer">
64
- <textarea id="prompt" rows="1" placeholder="Tell Codex what to do..." autocomplete="off"></textarea>
65
- <button type="submit" class="btn-send" id="sendBtn">Send</button>
66
- </form>
67
- </footer>
68
- <script>
69
- function getApiKey() { return sessionStorage.getItem("wip_api_key"); }
70
- function getHandle() { return sessionStorage.getItem("wip_handle") || ""; }
71
-
72
- function ensureSignedIn() {
73
- if (!getApiKey()) {
74
- location.href = "/app/login.html?next=" + encodeURIComponent(location.pathname);
75
- return false;
76
- }
77
- return true;
78
- }
79
-
80
- function parsePath() {
81
- // /:handle/codex-remote-control/:threadId
82
- const m = location.pathname.match(/^\/([^/]+)\/codex-remote-control\/([^/]+)\/?$/);
83
- if (!m) return null;
84
- return { handle: decodeURIComponent(m[1]), threadId: decodeURIComponent(m[2]) };
85
- }
86
-
87
- function setPresence(state) {
88
- const dot = document.getElementById("presence");
89
- dot.classList.remove("online", "offline");
90
- if (state === "online") dot.classList.add("online");
91
- if (state === "offline") dot.classList.add("offline");
92
- dot.title = state;
93
- }
94
-
95
- function appendEvent(html, kind) {
96
- const log = document.getElementById("log");
97
- const div = document.createElement("div");
98
- div.className = "event " + (kind || "");
99
- div.innerHTML = html;
100
- log.appendChild(div);
101
- log.scrollTop = log.scrollHeight;
102
- return div;
103
- }
104
-
105
- function escapeHtml(s) {
106
- return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
107
- }
108
-
109
- function renderItem(item) {
110
- if (item.type === "agent_message") {
111
- return appendEvent('<div class="meta">codex</div>' + escapeHtml(item.text || "").replace(/\n/g, "<br>"), "agent_message");
112
- }
113
- if (item.type === "command_execution") {
114
- const status = (item.status || "").toString();
115
- const out = item.aggregated_output ? '\n\n' + item.aggregated_output : "";
116
- return appendEvent(
117
- '<div class="meta">$ ' + escapeHtml(status) + (item.exit_code != null ? " (exit " + item.exit_code + ")" : "") + '</div>' +
118
- '<pre>' + escapeHtml(item.command || "") + escapeHtml(out) + '</pre>',
119
- "command_execution" + (status === "failed" ? " failed" : ""),
120
- );
121
- }
122
- if (item.type === "reasoning") {
123
- return appendEvent('<div class="meta">reasoning</div>' + escapeHtml(item.text || ""), "reasoning");
124
- }
125
- return appendEvent('<div class="meta">' + escapeHtml(item.type || "item") + '</div><pre>' + escapeHtml(JSON.stringify(item, null, 2)) + '</pre>', "item");
126
- }
127
-
128
- let ws = null;
129
- let pendingId = 1;
130
-
131
- function send(req) {
132
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
133
- ws.send(JSON.stringify(req));
134
- }
135
-
136
- function connect(threadId) {
137
- const apiKey = getApiKey();
138
- const proto = location.protocol === "https:" ? "wss:" : "ws:";
139
- const url = proto + "//" + location.host + "/api/codex-relay/web/" + encodeURIComponent(threadId) + "?token=" + encodeURIComponent(apiKey);
140
- ws = new WebSocket(url);
141
-
142
- ws.addEventListener("open", () => {
143
- setPresence("online");
144
- appendEvent("connected. open this thread in Codex on your Mac if it's not already.", "system");
145
- });
146
-
147
- ws.addEventListener("close", (ev) => {
148
- setPresence("offline");
149
- appendEvent("disconnected (code " + ev.code + ")", "system");
150
- });
151
-
152
- ws.addEventListener("error", () => {
153
- setPresence("offline");
154
- });
155
-
156
- ws.addEventListener("message", (ev) => {
157
- let msg;
158
- try { msg = JSON.parse(ev.data); } catch { return; }
159
- if (msg.type === "session.started") {
160
- // Daemon assigned a temp id; the real thread id will arrive in thread.started.
161
- return;
162
- }
163
- if (msg.type === "session.event") {
164
- const evt = msg.event || {};
165
- if (evt.type === "thread.started") {
166
- // ok
167
- return;
168
- }
169
- if (evt.type === "turn.started") {
170
- document.getElementById("stopBtn").disabled = false;
171
- return;
172
- }
173
- if (evt.type === "item.completed" && evt.item) {
174
- renderItem(evt.item);
175
- return;
176
- }
177
- if (evt.type === "item.started") {
178
- return; // skip; we render on completed for now
179
- }
180
- if (evt.type === "turn.completed") {
181
- document.getElementById("stopBtn").disabled = true;
182
- const u = evt.usage;
183
- if (u) appendEvent("turn complete (" + (u.input_tokens || 0) + " in / " + (u.output_tokens || 0) + " out)", "system");
184
- else appendEvent("turn complete", "system");
185
- return;
186
- }
187
- if (evt.type === "turn.failed") {
188
- document.getElementById("stopBtn").disabled = true;
189
- appendEvent("turn failed: " + (evt.error && evt.error.message ? evt.error.message : "unknown"), "error");
190
- return;
191
- }
192
- return;
193
- }
194
- if (msg.type === "ack") return;
195
- if (msg.type === "error") {
196
- appendEvent("error: " + (msg.message || ""), "error");
197
- return;
198
- }
199
- });
200
- }
201
-
202
- function init() {
203
- if (!ensureSignedIn()) return;
204
- const parsed = parsePath();
205
- if (!parsed) {
206
- appendEvent("Invalid URL. Expected /<handle>/codex-remote-control/<thread-id>.", "error");
207
- return;
208
- }
209
- document.getElementById("threadId").textContent = parsed.threadId;
210
- connect(parsed.threadId);
211
-
212
- // Open or attach to the session on the daemon. session.start returns a temp
213
- // sessionId; the actual thread.id arrives via thread.started in the stream.
214
- setTimeout(() => {
215
- send({ type: "session.start", id: "open-" + (pendingId += 1) });
216
- }, 250);
217
-
218
- document.getElementById("composer").addEventListener("submit", (ev) => {
219
- ev.preventDefault();
220
- const input = document.getElementById("prompt");
221
- const text = input.value.trim();
222
- if (!text) return;
223
- input.value = "";
224
- appendEvent('<div class="meta">you</div>' + escapeHtml(text), "user");
225
- send({
226
- type: "session.send",
227
- id: "send-" + (pendingId += 1),
228
- sessionId: parsed.threadId,
229
- prompt: text,
230
- });
231
- });
232
-
233
- document.getElementById("stopBtn").addEventListener("click", () => {
234
- send({ type: "session.interrupt", id: "stop-" + (pendingId += 1), sessionId: parsed.threadId });
235
- });
236
-
237
- // Submit on Cmd+Enter / Ctrl+Enter; auto-resize.
238
- const ta = document.getElementById("prompt");
239
- ta.addEventListener("input", () => {
240
- ta.style.height = "auto";
241
- ta.style.height = Math.min(120, ta.scrollHeight) + "px";
242
- });
243
- ta.addEventListener("keydown", (ev) => {
244
- if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
245
- ev.preventDefault();
246
- document.getElementById("composer").requestSubmit();
247
- }
248
- });
249
- }
250
-
251
- init();
252
- </script>
253
- </body>
254
- </html>