@lifeaitools/clauth 2.1.1 → 2.10.1

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 (98) hide show
  1. package/.clauth-skill/references/keys-guide.md +270 -270
  2. package/cli/api.js +238 -238
  3. package/cli/commands/install.js +413 -396
  4. package/cli/commands/login.test.js +49 -1
  5. package/cli/commands/ops-install.js +11 -10
  6. package/cli/commands/ops.test.js +270 -0
  7. package/cli/commands/scrub.test.js +70 -0
  8. package/cli/commands/serve.js +2618 -11238
  9. package/cli/commands/uninstall.js +164 -164
  10. package/cli/dashboard/dashboard.js +592 -0
  11. package/cli/dashboard/global.css +65 -0
  12. package/cli/dashboard/panel-element.js +79 -0
  13. package/cli/dashboard/panels/build-status-panel.css +12 -0
  14. package/cli/dashboard/panels/build-status-panel.js +86 -0
  15. package/cli/dashboard/panels/panel-registry.js +58 -0
  16. package/cli/dashboard/panels/services-panel.css +95 -0
  17. package/cli/dashboard/panels/services-panel.js +872 -0
  18. package/cli/dashboard/panels/surfaces-panel.css +59 -0
  19. package/cli/dashboard/panels/surfaces-panel.js +474 -0
  20. package/cli/dashboard/panels/toolbar-panel.css +42 -0
  21. package/cli/dashboard/panels/toolbar-panel.js +374 -0
  22. package/cli/dashboard/panels/tunnel-panel.css +61 -0
  23. package/cli/dashboard/panels/tunnel-panel.js +688 -0
  24. package/cli/dashboard/panels/webdav-panel.css +38 -0
  25. package/cli/dashboard/panels/webdav-panel.js +166 -0
  26. package/cli/fingerprint.test.js +95 -0
  27. package/cli/http/components/callagent-terminal-component.js +310 -0
  28. package/cli/http/components/dashboard-component.js +123 -0
  29. package/cli/http/components/inbox-component.js +41 -0
  30. package/cli/http/components/mcp-transport-component.js +275 -0
  31. package/cli/http/components/oauth-component.js +207 -0
  32. package/cli/http/components/service-crud-component.js +288 -0
  33. package/cli/http/components/supervisor-component.js +104 -0
  34. package/cli/http/components/system-component.js +184 -0
  35. package/cli/http/components/tunnel-component.js +508 -0
  36. package/cli/http/components/vault-auth-component.js +264 -0
  37. package/cli/http/components/watchdog-component.js +28 -0
  38. package/cli/http/request-utils.js +21 -0
  39. package/cli/http/servers/ops-server.js +335 -0
  40. package/cli/index.js +5 -91
  41. package/cli/lib/fs-git.test.js +467 -0
  42. package/cli/mcp/providers/call-agent-provider.js +30 -0
  43. package/cli/mcp/providers/clauth-core-provider.js +461 -0
  44. package/cli/mcp/providers/ops-provider.js +42 -0
  45. package/cli/mcp/providers/terminal-provider.js +81 -0
  46. package/cli/mcp/tool-registry.js +48 -0
  47. package/cli/mcp/tool-schemas.js +822 -0
  48. package/cli/mcp/tool-schemas.test.js +49 -0
  49. package/cli/ops/pm2-preflight.js +54 -0
  50. package/cli/recovery.js +6 -0
  51. package/cli/recovery.test.js +179 -0
  52. package/cli/services/agent-pool-instance.js +38 -0
  53. package/cli/services/claude-binary.js +29 -0
  54. package/cli/services/delegation-lane-instance.js +35 -0
  55. package/cli/services/delegation-lane-instance.test.js +37 -0
  56. package/cli/services/file-io.js +38 -0
  57. package/cli/services/file-io.test.js +10 -0
  58. package/cli/services/fs-mount.js +66 -0
  59. package/cli/services/fs-mount.test.js +14 -0
  60. package/cli/services/fs-upload-sessions.js +20 -0
  61. package/cli/services/fs-upload-sessions.test.js +14 -0
  62. package/cli/services/git-exec.js +37 -0
  63. package/cli/services/git-exec.test.js +41 -0
  64. package/cli/services/git-import-guard.js +28 -0
  65. package/cli/services/git-import-guard.test.js +18 -0
  66. package/cli/services/service-status.js +191 -0
  67. package/cli/services/service-status.test.js +39 -0
  68. package/cli/services/terminal-dispatch.js +135 -0
  69. package/cli/services/terminal-dispatch.test.js +9 -0
  70. package/cli/services/terminal-registry.js +39 -0
  71. package/cli/services/terminal-registry.test.js +31 -0
  72. package/cli/services/terminal-sessions.js +100 -0
  73. package/cli/services/terminal-sessions.test.js +26 -0
  74. package/cli/services/terminal-window.js +106 -0
  75. package/cli/services/vault-read.js +14 -0
  76. package/cli/services/vault-read.test.js +35 -0
  77. package/cli/supervisor-registry.js +116 -0
  78. package/cli/supervisor-registry.test.js +165 -0
  79. package/cli/supervisor-ui.test.js +230 -30
  80. package/cli/webdav-config.js +84 -0
  81. package/cli/webdav-config.test.js +79 -0
  82. package/cli/webdav-obscure.js +23 -0
  83. package/cli/webdav-obscure.test.js +41 -0
  84. package/cli/webdav-rclone-discovery.js +44 -0
  85. package/cli/webdav-service.js +30 -118
  86. package/cli/webdav-service.test.js +107 -0
  87. package/install.ps1 +102 -102
  88. package/install.sh +49 -49
  89. package/package.json +2 -2
  90. package/scripts/bootstrap.cjs +121 -121
  91. package/supabase/functions/auth-vault/index.ts +350 -350
  92. package/supabase/migrations/001_clauth_schema.sql +94 -94
  93. package/supabase/migrations/002_vault_helpers.sql +90 -90
  94. package/supabase/migrations/20260317_lockout.sql +26 -26
  95. package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
  96. package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
  97. package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
  98. package/cli/commands/codevelop.js +0 -1190
@@ -0,0 +1,38 @@
1
+ /* cli/dashboard/panels/webdav-panel.css
2
+ The WebDAV mounts panel (<clauth-webdav-panel>) -- its own copy of the
3
+ subset of tunnel-panel.css's rules this panel's template actually uses
4
+ (.tunnel-panel base look, recolored to a column layout; .tunnel-dot;
5
+ .btn-tunnel-stop), since Shadow DOM cannot see another panel's external
6
+ stylesheet. Depends on the tokens in ../global.css, loaded before this
7
+ file by panel-element.js's connectedCallback(). */
8
+ .webdav-panel{background:var(--bg-tunnel);border:1px solid var(--border-dark);border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:flex;flex-direction:column;align-items:stretch;gap:8px}
9
+ .webdav-header{display:flex;align-items:center;gap:10px;width:100%}
10
+ .webdav-title{color:var(--text-bright);font-size:.88rem}
11
+ .webdav-status{font-size:.78rem;color:var(--grey-dim)}
12
+ .webdav-add-btn{margin-left:auto;font-size:.75rem;padding:4px 10px}
13
+
14
+ .tunnel-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
15
+ .tunnel-dot.off{background:var(--grey-dim)}
16
+ .tunnel-dot.starting{background:var(--orange);animation:pulse 1s infinite}
17
+ .tunnel-dot.live{background:var(--green-bright);animation:pulse 2s infinite}
18
+ .tunnel-dot.err{background:var(--red-bright)}
19
+
20
+ .btn-tunnel-stop{background:var(--bg-card);color:var(--red);border:1px solid var(--border);padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500}
21
+ .btn-tunnel-stop:hover{background:var(--red-hover-bg);border-color:var(--red)}
22
+
23
+ .webdav-table{width:100%;border-collapse:collapse;font-size:.82rem}
24
+ .webdav-thead-row{color:var(--grey);text-align:left}
25
+ .webdav-th{padding:4px 8px;border-bottom:1px solid var(--bg-card)}
26
+ .webdav-th-actions{width:60px}
27
+ .webdav-row{color:var(--text-bright)}
28
+ .webdav-td{padding:4px 8px;border-bottom:1px solid var(--bg-dark)}
29
+ .webdav-td-name{font-family:'Courier New',monospace;color:var(--mint)}
30
+ .webdav-td-path{font-family:'Courier New',monospace;color:var(--grey)}
31
+ .webdav-del-btn{font-size:.7rem;padding:2px 8px;color:var(--red-bright)}
32
+
33
+ .webdav-add-form{padding:8px 0;border-top:1px solid var(--bg-card)}
34
+ .webdav-add-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
35
+ .webdav-input{background:var(--bg-darker);border:1px solid var(--border);border-radius:4px;color:var(--text-bright);padding:5px 8px;font-size:.82rem}
36
+ .webdav-input-name{width:120px}
37
+ .webdav-input-path{flex:1;min-width:160px}
38
+ .webdav-save-btn{background:var(--bg-copy)}
@@ -0,0 +1,166 @@
1
+ // cli/dashboard/panels/webdav-panel.js
2
+ // The WebDAV mounts panel, as a real Web Component: <clauth-webdav-panel>.
3
+ // Owns its own Shadow DOM for the mount table, add/delete mounts, and the
4
+ // live daemon-status dot -- same conversion this file's own header comment
5
+ // used to describe as "extracted verbatim"; this pass takes it the rest of
6
+ // the way to a genuinely isolated custom element, matching tunnel-panel.js
7
+ // (the reference implementation) and toolbar-panel.js.
8
+ //
9
+ // Loaded as a plain classic <script> (not type="module") BEFORE dashboard.js
10
+ // -- same pattern as every other panel script.
11
+ class ClauthWebdavPanel extends window.ClauthPanelElement {
12
+ static tagName = "clauth-webdav-panel";
13
+ static cssHref = "/dashboard/panels/webdav-panel.css";
14
+
15
+ template() {
16
+ return `
17
+ <div class="webdav-panel">
18
+ <div class="webdav-header">
19
+ <div class="tunnel-dot off" id="webdav-dot"></div>
20
+ <strong class="webdav-title">WebDAV Mounts</strong>
21
+ <span id="webdav-status" class="webdav-status"></span>
22
+ <button class="btn-tunnel-stop webdav-add-btn" data-action="addMountPrompt">+ Add Mount</button>
23
+ </div>
24
+ <table id="webdav-table" class="webdav-table" style="display:none">
25
+ <thead><tr class="webdav-thead-row">
26
+ <th class="webdav-th">Name</th>
27
+ <th class="webdav-th">Path</th>
28
+ <th class="webdav-th webdav-th-actions"></th>
29
+ </tr></thead>
30
+ <tbody id="webdav-mounts"></tbody>
31
+ </table>
32
+ <div id="webdav-add-form" class="webdav-add-form" style="display:none">
33
+ <div class="webdav-add-row">
34
+ <input id="mount-name" class="webdav-input webdav-input-name" placeholder="name (e.g. projects)">
35
+ <input id="mount-path" class="webdav-input webdav-input-path" placeholder="C:\\path\\to\\folder">
36
+ <button class="btn-tunnel-stop webdav-save-btn" data-action="submitMount">Save</button>
37
+ <button class="btn-tunnel-stop" data-action="hideAddForm">Cancel</button>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ `;
42
+ }
43
+
44
+ mount() {
45
+ this.loadWebdavMounts();
46
+ }
47
+
48
+ // ── WebDAV mount management ─────────────────
49
+ async loadWebdavMounts() {
50
+ try {
51
+ const r = await apiFetch("/webdav/config");
52
+ if (!r) return;
53
+ const tbody = this.$("#webdav-mounts");
54
+ const table = this.$("#webdav-table");
55
+ const dot = this.$("#webdav-dot");
56
+ const status = this.$("#webdav-status");
57
+ tbody.innerHTML = "";
58
+ if (r.upstreams && r.upstreams.length > 0) {
59
+ table.style.display = "table";
60
+ r.upstreams.forEach(u => {
61
+ const tr = document.createElement("tr");
62
+ tr.className = "webdav-row";
63
+ tr.innerHTML =
64
+ '<td class="webdav-td webdav-td-name">' + esc(u.name) + '</td>' +
65
+ '<td class="webdav-td webdav-td-path">' + esc(u.path) + '</td>' +
66
+ '<td class="webdav-td"><button class="btn-tunnel-stop webdav-del-btn" data-action="deleteMount" data-mount-name="' + esc(u.name) + '">✕</button></td>';
67
+ tbody.appendChild(tr);
68
+ });
69
+ } else {
70
+ table.style.display = "none";
71
+ }
72
+ // Check ping for live status
73
+ const ping = await apiFetch("/ping");
74
+ if (ping && ping.webdav) {
75
+ const ws = ping.webdav;
76
+ dot.className = "tunnel-dot " + (ws.status === "running" ? "live" : ws.status === "starting" ? "starting" : "off");
77
+ status.textContent = ws.status === "running" ? "running on :" + ws.port : ws.status;
78
+ }
79
+ } catch {}
80
+ }
81
+
82
+ addMountPrompt() {
83
+ this.$("#webdav-add-form").style.display = "block";
84
+ this.$("#mount-name").value = "";
85
+ this.$("#mount-path").value = "";
86
+ this.$("#mount-name").focus();
87
+ }
88
+
89
+ hideAddForm() {
90
+ this.$("#webdav-add-form").style.display = "none";
91
+ }
92
+
93
+ async submitMount() {
94
+ const name = this.$("#mount-name").value.trim();
95
+ const mountPath = this.$("#mount-path").value.trim();
96
+ if (!name || !mountPath) return;
97
+ const r = await fetch(BASE + "/webdav/mounts", {
98
+ method: "POST",
99
+ headers: writeHeaders({ "Content-Type": "application/json" }),
100
+ body: JSON.stringify({ name, path: mountPath })
101
+ }).then(r => r.json());
102
+ if (r.error) { alert(r.error); return; }
103
+ this.hideAddForm();
104
+ this.loadWebdavMounts();
105
+ }
106
+
107
+ async deleteMount(name) {
108
+ if (!confirm("Remove mount '" + name + "'?")) return;
109
+ await fetch(BASE + "/webdav/mounts/" + encodeURIComponent(name), {
110
+ method: "DELETE",
111
+ headers: writeHeaders()
112
+ });
113
+ this.loadWebdavMounts();
114
+ }
115
+
116
+ get actions() {
117
+ return {
118
+ addMountPrompt: () => this.addMountPrompt(),
119
+ hideAddForm: () => this.hideAddForm(),
120
+ // Both dispatch through the window bridges below (NOT this.submitMount()
121
+ // / this.deleteMount() directly) -- installWriteAccessGuards() wraps the
122
+ // GLOBAL name with the write-token acquire-and-retry gate. Calling the
123
+ // instance method directly would silently bypass that gate, restoring
124
+ // the exact write-lock defect this mechanism exists to prevent. Same
125
+ // pattern as tunnel-panel.js's wizSubmitCfToken bridge.
126
+ submitMount: () => window.submitMount(),
127
+ deleteMount: (e, el) => window.deleteMount(el.dataset.mountName),
128
+ };
129
+ }
130
+ }
131
+
132
+ // Pure string-escape helper -- no shadow-DOM/instance state needed, so it
133
+ // stays a plain function rather than an instance method.
134
+ function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
135
+
136
+ customElements.define(ClauthWebdavPanel.tagName, ClauthWebdavPanel);
137
+
138
+ // Write-guard bridges: installWriteAccessGuards() (dashboard.js) wraps every
139
+ // name in WRITE_ACTIONS by reassigning window[name] to a guarded version --
140
+ // it can only do that for a real global function, not a class instance
141
+ // method. These thin globals delegate to the real implementation on the live
142
+ // panel instance; installWriteAccessGuards wraps THESE names, and the
143
+ // component's own action dispatcher calls window.submitMount()/
144
+ // window.deleteMount() (see the `actions` getter above), never
145
+ // this.submitMount()/this.deleteMount() directly, so the guard is never
146
+ // bypassed.
147
+ window.submitMount = () => {
148
+ const el = document.querySelector(ClauthWebdavPanel.tagName);
149
+ return el?.submitMount();
150
+ };
151
+ window.deleteMount = (name) => {
152
+ const el = document.querySelector(ClauthWebdavPanel.tagName);
153
+ return el?.deleteMount(name);
154
+ };
155
+
156
+ // Panel interface registration — see ./panel-registry.js. mount() is called
157
+ // explicitly by ClauthPanelRegistry.mountAll(), after every script has
158
+ // loaded -- connectedCallback() (panel-element.js) only builds the shadow
159
+ // DOM during HTML parsing, before dashboard.js (and apiFetch) exists.
160
+ window.ClauthPanelRegistry.register({
161
+ id: "webdav",
162
+ mount: () => {
163
+ const el = document.querySelector(ClauthWebdavPanel.tagName);
164
+ if (el) el.mount();
165
+ },
166
+ });
@@ -0,0 +1,95 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { getMachineHash, deriveToken, deriveSeedHash } from "./fingerprint.js";
8
+
9
+ // getMachineId() normally shells out to WMI/registry/ioreg, which this harness
10
+ // must never do (slow, platform-dependent, pollutes nothing but still real
11
+ // process spawns). CLAUTH_MACHINE_ID takes the container fast-path — no
12
+ // execSync, no cache file — which is exactly the path this suite needs.
13
+ function withMachineId(id, fn) {
14
+ const old = process.env.CLAUTH_MACHINE_ID;
15
+ process.env.CLAUTH_MACHINE_ID = id;
16
+ try { return fn(); } finally {
17
+ if (old === undefined) delete process.env.CLAUTH_MACHINE_ID;
18
+ else process.env.CLAUTH_MACHINE_ID = old;
19
+ }
20
+ }
21
+
22
+ test("getMachineHash is deterministic for the same machine id", () => {
23
+ const a = withMachineId("machine-a", () => getMachineHash());
24
+ const b = withMachineId("machine-a", () => getMachineHash());
25
+ assert.equal(a, b);
26
+ assert.match(a, /^[0-9a-f]{64}$/, "must be a sha256 hex digest");
27
+ });
28
+
29
+ test("getMachineHash differs for a different machine id — the whole point of a fingerprint", () => {
30
+ const a = withMachineId("machine-a", () => getMachineHash());
31
+ const b = withMachineId("machine-b", () => getMachineHash());
32
+ assert.notEqual(a, b);
33
+ });
34
+
35
+ test("CLAUTH_MACHINE_ID must not be empty", () => {
36
+ assert.throws(() => withMachineId(" ", () => getMachineHash()), /must not be empty/);
37
+ });
38
+
39
+ test("deriveToken is deterministic within the same 5-minute window", () => {
40
+ const hash = withMachineId("machine-a", () => getMachineHash());
41
+ const first = deriveToken("hunter2", hash);
42
+ const second = deriveToken("hunter2", hash);
43
+ assert.equal(first.token, second.token, "same password + hash + window must produce the same token");
44
+ assert.equal(first.timestamp, second.timestamp);
45
+ assert.equal(first.machineHash, hash);
46
+ assert.match(first.token, /^[0-9a-f]{64}$/);
47
+ });
48
+
49
+ test("deriveToken changes with the password — it is what authenticates, not the machine hash alone", () => {
50
+ const hash = withMachineId("machine-a", () => getMachineHash());
51
+ const a = deriveToken("password-one", hash);
52
+ const b = deriveToken("password-two", hash);
53
+ assert.notEqual(a.token, b.token);
54
+ });
55
+
56
+ test("deriveToken changes with the machine hash — a token from one machine must not verify on another", () => {
57
+ const hashA = withMachineId("machine-a", () => getMachineHash());
58
+ const hashB = withMachineId("machine-b", () => getMachineHash());
59
+ const a = deriveToken("hunter2", hashA);
60
+ const b = deriveToken("hunter2", hashB);
61
+ assert.notEqual(a.token, b.token);
62
+ });
63
+
64
+ test("deriveToken timestamp is a 5-minute window boundary, not the raw clock", () => {
65
+ const hash = withMachineId("machine-a", () => getMachineHash());
66
+ const { timestamp } = deriveToken("hunter2", hash);
67
+ const windowMs = 5 * 60 * 1000;
68
+ assert.equal(timestamp % windowMs, 0, "timestamp must be an exact window boundary, not Date.now()");
69
+ assert.ok(timestamp <= Date.now(), "the window boundary must not be in the future");
70
+ });
71
+
72
+ test("deriveSeedHash is deterministic and distinct from deriveToken's output", () => {
73
+ const hash = withMachineId("machine-a", () => getMachineHash());
74
+ const seed1 = deriveSeedHash(hash, "hunter2");
75
+ const seed2 = deriveSeedHash(hash, "hunter2");
76
+ assert.equal(seed1, seed2);
77
+ assert.match(seed1, /^[0-9a-f]{64}$/);
78
+ const { token } = deriveToken("hunter2", hash);
79
+ assert.notEqual(seed1, token, "seed hash and auth token must not collide — they protect different things");
80
+ });
81
+
82
+ test("deriveSeedHash changes with the password", () => {
83
+ const hash = withMachineId("machine-a", () => getMachineHash());
84
+ assert.notEqual(deriveSeedHash(hash, "one"), deriveSeedHash(hash, "two"));
85
+ });
86
+
87
+ test("machine id cache: a fresh CLAUTH_MACHINE_ID call never touches the WMI cache file", () => {
88
+ // Positive control that the cache mechanism exists and this test can see it,
89
+ // so "the cache file was untouched" below actually means something.
90
+ const cacheFile = path.join(os.tmpdir(), "clauth-machine.cache");
91
+ const before = fs.existsSync(cacheFile) ? fs.statSync(cacheFile).mtimeMs : null;
92
+ withMachineId("container-fast-path", () => getMachineHash());
93
+ const after = fs.existsSync(cacheFile) ? fs.statSync(cacheFile).mtimeMs : null;
94
+ assert.equal(before, after, "the container-id fast path must not read or write the WMI cache file");
95
+ });
@@ -0,0 +1,310 @@
1
+ // cli/http/components/callagent-terminal-component.js
2
+ // The CallAgent + Terminal + Channel component: POST/GET /call-agent
3
+ // (lean one-shot dispatch through the warm agent pool, plus job
4
+ // polling/show/hide), the 5 terminal-session routes (start/send/show/
5
+ // stop/recv), and POST /channel (Coolify/GitHub webhook receiver).
6
+ //
7
+ // call_agent routes share one CSRF/bearer guard (evaluateCallAgentGuard) --
8
+ // it's a pure, exported function, but test/call-agent-guard.test.mjs
9
+ // imports it directly FROM serve.js, so it must stay defined and
10
+ // exported there (a stable, tested public surface); passed here via
11
+ // ctx as a plain reference instead of re-imported, avoiding a real
12
+ // circular import (serve.js imports THIS file's register function).
13
+ // noAuthHost/requestHost were computed ONCE per-request in the original shared
14
+ // handler; each call_agent route here recomputes them locally from
15
+ // req.headers.host -- identical values, just no longer shared across
16
+ // routes in one dispatch function.
17
+ //
18
+ // runCallAgent/getCallAgentJob/resolveCallAgentToken stay defined in
19
+ // serve.js (core daemon infrastructure: Supabase job persistence, the
20
+ // AgentCron scheduler, getAgentPool()) and are passed via ctx as plain
21
+ // function references -- same avoid-circular-import DI pattern as
22
+ // call-agent-provider.js's registerCallAgentTool(runCallAgent).
23
+ //
24
+ // getAgentPool/getDelegationLane/startTerminalSession/
25
+ // stopTerminalSession/sendTerminalMessage/recvTerminalResponse/
26
+ // showTerminalSession/channelEvents/MAX_CHANNEL_EVENTS are all already
27
+ // separately-modularized service imports in serve.js (not closure
28
+ // state) -- imported directly here too, zero circularity, matching
29
+ // how cli/mcp/providers/terminal-provider.js already imports the same
30
+ // modules for the MCP-tool side of these same operations.
31
+ import { operation } from "../../supervisor-registry.js";
32
+ import { getAgentPool } from "../../services/agent-pool-instance.js";
33
+ import { getDelegationLane } from "../../services/delegation-lane-instance.js";
34
+ import { startTerminalSession, stopTerminalSession } from "../../services/terminal-sessions.js";
35
+ import { sendTerminalMessage, recvTerminalResponse } from "../../services/terminal-dispatch.js";
36
+ import { showTerminalSession } from "../../services/terminal-window.js";
37
+ import { channelEvents, MAX_CHANNEL_EVENTS } from "../../services/terminal-registry.js";
38
+ import crypto from "node:crypto";
39
+
40
+ const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
41
+
42
+ export function registerCallAgentTerminalRoutes(ctx) {
43
+ // ── call_agent (Gate B) ───────────────────────────────────────────────────
44
+ // POST /call-agent — lean one-shot Haiku dispatch through the warm pool.
45
+ // sync (default): blocks, returns { ok, package, jobId, model, ms }.
46
+ // async: returns { ok, jobId } immediately; poll GET /call-agent/:jobId.
47
+ ctx.registerWriteRoute("POST", "/call-agent", async (req, res, url) => {
48
+ const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
49
+ const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
50
+ // call_agent spawns real `claude` workers. Like legacy /dispatch, it is a
51
+ // local-tool-only path: reject any browser Origin (CSRF) and, on a noauth
52
+ // tunnel host (clauth.regendevcorp.com etc.), require a valid
53
+ // `Authorization: Bearer <CALL_AGENT_TOKEN>`. Local (127.0.0.1) stays
54
+ // token-free. A noauth host without a valid bearer is still rejected.
55
+ {
56
+ const expectedToken = await ctx.resolveCallAgentToken();
57
+ const verdict = ctx.evaluateCallAgentGuard({
58
+ origin: req.headers.origin,
59
+ noAuthHost,
60
+ authHeader: req.headers.authorization,
61
+ expectedToken,
62
+ });
63
+ if (!verdict.allow) {
64
+ res.writeHead(verdict.status, { "Content-Type": "application/json", ...ctx.CORS });
65
+ return res.end(JSON.stringify({ ok: false, error: verdict.error, host: requestHost, ...(verdict.origin ? { origin: verdict.origin } : {}) }));
66
+ }
67
+ }
68
+ let body = "";
69
+ req.on("data", (d) => (body += d));
70
+ req.on("end", async () => {
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(body || "{}");
74
+ } catch {
75
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
76
+ return res.end(JSON.stringify({ ok: false, error: "invalid JSON" }));
77
+ }
78
+ const result = await ctx.runCallAgent(parsed);
79
+ const status = result.ok ? 200 : (result.error === "binary_not_found" ? 503 : 400);
80
+ res.writeHead(status, { "Content-Type": "application/json", ...ctx.CORS });
81
+ res.end(JSON.stringify(result));
82
+ });
83
+ return;
84
+ });
85
+ // GET /call-agent/:jobId — poll an async (or completed) call_agent job.
86
+ ctx.registerPatternRoute("GET", /^\/call-agent\/([^/]+)$/, async (req, res, match, url) => {
87
+ const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
88
+ const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
89
+ {
90
+ const expectedToken = await ctx.resolveCallAgentToken();
91
+ const verdict = ctx.evaluateCallAgentGuard({
92
+ origin: req.headers.origin,
93
+ noAuthHost,
94
+ authHeader: req.headers.authorization,
95
+ expectedToken,
96
+ });
97
+ if (!verdict.allow) {
98
+ res.writeHead(verdict.status, { "Content-Type": "application/json", ...ctx.CORS });
99
+ return res.end(JSON.stringify({ ok: false, error: verdict.error, host: requestHost, ...(verdict.origin ? { origin: verdict.origin } : {}) }));
100
+ }
101
+ }
102
+ const jobId = decodeURIComponent(match[1]);
103
+ const job = await ctx.getCallAgentJob(jobId);
104
+ if (!job) {
105
+ res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
106
+ return res.end(JSON.stringify({ ok: false, error: "not_found", jobId }));
107
+ }
108
+ res.writeHead(200, { "Content-Type": "application/json", ...ctx.CORS });
109
+ return res.end(JSON.stringify({ ok: job.status === "completed", ...job }));
110
+ });
111
+ // POST /call-agent/:jobId/show — bring a running job's console window to the
112
+ // foreground. POST /call-agent/:jobId/hide — re-hide it. Local-only (127.0.0.1),
113
+ // same guard as the poll route above.
114
+ ctx.registerPatternRoute("POST", /^\/call-agent\/([^/]+)\/(show|hide)$/, async (req, res, match, url) => {
115
+ const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
116
+ const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
117
+ {
118
+ const expectedToken = await ctx.resolveCallAgentToken();
119
+ const verdict = ctx.evaluateCallAgentGuard({
120
+ origin: req.headers.origin,
121
+ noAuthHost,
122
+ authHeader: req.headers.authorization,
123
+ expectedToken,
124
+ });
125
+ if (!verdict.allow) {
126
+ res.writeHead(verdict.status, { "Content-Type": "application/json", ...ctx.CORS });
127
+ return res.end(JSON.stringify({ ok: false, error: verdict.error, host: requestHost, ...(verdict.origin ? { origin: verdict.origin } : {}) }));
128
+ }
129
+ }
130
+ const jobId = decodeURIComponent(match[1]);
131
+ const action = match[2];
132
+ const result = action === "show" ? await getAgentPool().showJob(jobId) : await getAgentPool().hideJob(jobId);
133
+ const status = result.reason === "job_not_found" ? 404 : 200;
134
+ res.writeHead(status, { "Content-Type": "application/json", ...ctx.CORS });
135
+ return res.end(JSON.stringify({ ok: !!result.shown, ...result, jobId, action }));
136
+ });
137
+ // GET /call-agent — pool stats (health/debug, no credentials).
138
+ ctx.registerWriteRoute("GET", "/call-agent", async (req, res, url) => {
139
+ const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
140
+ const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
141
+ {
142
+ const expectedToken = await ctx.resolveCallAgentToken();
143
+ const verdict = ctx.evaluateCallAgentGuard({
144
+ origin: req.headers.origin,
145
+ noAuthHost,
146
+ authHeader: req.headers.authorization,
147
+ expectedToken,
148
+ });
149
+ if (!verdict.allow) {
150
+ res.writeHead(verdict.status, { "Content-Type": "application/json", ...ctx.CORS });
151
+ return res.end(JSON.stringify({ ok: false, error: verdict.error, host: requestHost, ...(verdict.origin ? { origin: verdict.origin } : {}) }));
152
+ }
153
+ }
154
+ const poolStats = getAgentPool().stats();
155
+ // WP-P7: only attach delegation-lane stats when the gate is on, so the
156
+ // stats payload is byte-identical to today when delegation is OFF.
157
+ const lane = getDelegationLane();
158
+ const extra = lane ? { delegation: lane.stats() } : {};
159
+ res.writeHead(200, { "Content-Type": "application/json", ...ctx.CORS });
160
+ return res.end(JSON.stringify({ ok: true, ...poolStats, ...extra }));
161
+ });
162
+ // POST /terminal/start — start a named terminal session
163
+ ctx.registerWriteRoute("POST", "/terminal/start", async (req, res, url) => {
164
+ let body = "";
165
+ req.on("data", d => body += d);
166
+ req.on("end", () => {
167
+ try {
168
+ const { name, knowledge_tier, context_md } = JSON.parse(body);
169
+ if (!name) {
170
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
171
+ return res.end(JSON.stringify({ error: "name required" }));
172
+ }
173
+ const tier = knowledge_tier || 'db_only';
174
+ const result = startTerminalSession(name, tier, context_md || null);
175
+ operation("terminal.start", { name, tier }, null, { ok: !result.error, error: result.error });
176
+ const status = result.error ? 503 : 200;
177
+ res.writeHead(status, { "Content-Type": "application/json", ...ctx.CORS });
178
+ res.end(JSON.stringify(result));
179
+ } catch {
180
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
181
+ res.end(JSON.stringify({ error: "invalid JSON" }));
182
+ }
183
+ });
184
+ return;
185
+ });
186
+ // POST /terminal/send — send a message to a running terminal session
187
+ ctx.registerWriteRoute("POST", "/terminal/send", async (req, res, url) => {
188
+ let body = "";
189
+ req.on("data", d => body += d);
190
+ req.on("end", () => {
191
+ try {
192
+ const { session_id, message } = JSON.parse(body);
193
+ if (!session_id || !message) {
194
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
195
+ return res.end(JSON.stringify({ error: "session_id and message required" }));
196
+ }
197
+ const result = sendTerminalMessage(session_id, message);
198
+ operation("terminal.send", { session_id }, null, { ok: !result.error, error: result.error });
199
+ const status = result.error === 'session_busy' ? 409 : result.error ? 404 : 200;
200
+ res.writeHead(status, { "Content-Type": "application/json", ...ctx.CORS });
201
+ res.end(JSON.stringify(result));
202
+ } catch {
203
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
204
+ res.end(JSON.stringify({ error: "invalid JSON" }));
205
+ }
206
+ });
207
+ return;
208
+ });
209
+ // POST /terminal/show — reveal/focus an active terminal session or open a visible inspector
210
+ ctx.registerWriteRoute("POST", "/terminal/show", async (req, res, url) => {
211
+ let body = "";
212
+ req.on("data", d => body += d);
213
+ req.on("end", () => {
214
+ try {
215
+ const { session_id } = JSON.parse(body);
216
+ if (!session_id) {
217
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
218
+ return res.end(JSON.stringify({ error: "session_id required" }));
219
+ }
220
+ const result = showTerminalSession(session_id);
221
+ const status = result.error === 'not_found' ? 404 : result.error ? 500 : 200;
222
+ res.writeHead(status, { "Content-Type": "application/json", ...ctx.CORS });
223
+ res.end(JSON.stringify(result));
224
+ } catch {
225
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
226
+ res.end(JSON.stringify({ error: "invalid JSON" }));
227
+ }
228
+ });
229
+ return;
230
+ });
231
+ // POST /terminal/stop — stop a terminal session
232
+ ctx.registerWriteRoute("POST", "/terminal/stop", async (req, res, url) => {
233
+ let body = "";
234
+ req.on("data", d => body += d);
235
+ req.on("end", () => {
236
+ try {
237
+ const { session_id } = JSON.parse(body);
238
+ if (!session_id) {
239
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
240
+ return res.end(JSON.stringify({ error: "session_id required" }));
241
+ }
242
+ const result = stopTerminalSession(session_id);
243
+ operation("terminal.stop", { session_id }, null, { ok: !result.error, error: result.error });
244
+ const status = result.error ? 404 : 200;
245
+ res.writeHead(status, { "Content-Type": "application/json", ...ctx.CORS });
246
+ res.end(JSON.stringify(result));
247
+ } catch {
248
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
249
+ res.end(JSON.stringify({ error: "invalid JSON" }));
250
+ }
251
+ });
252
+ return;
253
+ });
254
+ // POST /terminal/recv — read last response from a terminal session
255
+ ctx.registerWriteRoute("POST", "/terminal/recv", async (req, res, url) => {
256
+ let body = "";
257
+ req.on("data", d => body += d);
258
+ req.on("end", () => {
259
+ try {
260
+ const { session_id } = JSON.parse(body);
261
+ if (!session_id) {
262
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
263
+ return res.end(JSON.stringify({ error: "session_id required" }));
264
+ }
265
+ const result = recvTerminalResponse(session_id);
266
+ const status = result.error ? 404 : 200;
267
+ res.writeHead(status, { "Content-Type": "application/json", ...ctx.CORS });
268
+ res.end(JSON.stringify(result));
269
+ } catch {
270
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
271
+ res.end(JSON.stringify({ error: "invalid JSON" }));
272
+ }
273
+ });
274
+ return;
275
+ });
276
+ // POST /channel — receive Coolify/GitHub webhook events
277
+ ctx.registerWriteRoute("POST", "/channel", async (req, res, url) => {
278
+ let body = "";
279
+ req.on("data", d => (body += d));
280
+ req.on("end", () => {
281
+ try {
282
+ const payload = JSON.parse(body || "{}");
283
+ const { event, resource, status: evtStatus, url, repository } = payload;
284
+ if (!event) {
285
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
286
+ return res.end(JSON.stringify({ error: "event field required" }));
287
+ }
288
+ const eventId = crypto.randomUUID();
289
+ const entry = {
290
+ id: eventId,
291
+ event,
292
+ resource: resource || null,
293
+ status: evtStatus || null,
294
+ url: url || null,
295
+ repository: repository || null,
296
+ created_at: new Date().toISOString(),
297
+ };
298
+ channelEvents.push(entry);
299
+ if (channelEvents.length > MAX_CHANNEL_EVENTS) channelEvents.shift();
300
+ console.log(`[channel] queued event ${eventId} type=${event}`);
301
+ operation("channel.event", { event, resource, repository }, null, { ok: true, event_id: eventId });
302
+ return ctx.ok(res, { received: true, event_id: eventId });
303
+ } catch {
304
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
305
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
306
+ }
307
+ });
308
+ return;
309
+ });
310
+ }