@lifeaitools/clauth 2.1.1 → 2.10.0
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/.clauth-skill/references/keys-guide.md +270 -270
- package/cli/api.js +238 -238
- package/cli/commands/install.js +413 -396
- package/cli/commands/login.test.js +49 -1
- package/cli/commands/ops-install.js +11 -10
- package/cli/commands/ops.test.js +270 -0
- package/cli/commands/scrub.test.js +70 -0
- package/cli/commands/serve.js +2613 -11219
- package/cli/commands/uninstall.js +164 -164
- package/cli/dashboard/dashboard.js +592 -0
- package/cli/dashboard/global.css +65 -0
- package/cli/dashboard/panel-element.js +79 -0
- package/cli/dashboard/panels/build-status-panel.css +12 -0
- package/cli/dashboard/panels/build-status-panel.js +98 -0
- package/cli/dashboard/panels/panel-registry.js +58 -0
- package/cli/dashboard/panels/services-panel.css +95 -0
- package/cli/dashboard/panels/services-panel.js +872 -0
- package/cli/dashboard/panels/surfaces-panel.css +59 -0
- package/cli/dashboard/panels/surfaces-panel.js +474 -0
- package/cli/dashboard/panels/toolbar-panel.css +42 -0
- package/cli/dashboard/panels/toolbar-panel.js +374 -0
- package/cli/dashboard/panels/tunnel-panel.css +61 -0
- package/cli/dashboard/panels/tunnel-panel.js +688 -0
- package/cli/dashboard/panels/webdav-panel.css +38 -0
- package/cli/dashboard/panels/webdav-panel.js +166 -0
- package/cli/fingerprint.test.js +95 -0
- package/cli/http/components/callagent-terminal-component.js +310 -0
- package/cli/http/components/dashboard-component.js +123 -0
- package/cli/http/components/inbox-component.js +41 -0
- package/cli/http/components/mcp-transport-component.js +275 -0
- package/cli/http/components/oauth-component.js +207 -0
- package/cli/http/components/service-crud-component.js +288 -0
- package/cli/http/components/supervisor-component.js +104 -0
- package/cli/http/components/system-component.js +184 -0
- package/cli/http/components/tunnel-component.js +508 -0
- package/cli/http/components/vault-auth-component.js +264 -0
- package/cli/http/components/watchdog-component.js +28 -0
- package/cli/http/request-utils.js +21 -0
- package/cli/http/servers/ops-server.js +335 -0
- package/cli/index.js +5 -91
- package/cli/lib/fs-git.test.js +467 -0
- package/cli/mcp/providers/call-agent-provider.js +30 -0
- package/cli/mcp/providers/clauth-core-provider.js +461 -0
- package/cli/mcp/providers/ops-provider.js +42 -0
- package/cli/mcp/providers/terminal-provider.js +81 -0
- package/cli/mcp/tool-registry.js +48 -0
- package/cli/mcp/tool-schemas.js +822 -0
- package/cli/mcp/tool-schemas.test.js +49 -0
- package/cli/ops/pm2-preflight.js +54 -0
- package/cli/recovery.js +6 -0
- package/cli/recovery.test.js +179 -0
- package/cli/services/agent-pool-instance.js +38 -0
- package/cli/services/claude-binary.js +29 -0
- package/cli/services/delegation-lane-instance.js +35 -0
- package/cli/services/delegation-lane-instance.test.js +37 -0
- package/cli/services/file-io.js +38 -0
- package/cli/services/file-io.test.js +10 -0
- package/cli/services/fs-mount.js +66 -0
- package/cli/services/fs-mount.test.js +14 -0
- package/cli/services/fs-upload-sessions.js +20 -0
- package/cli/services/fs-upload-sessions.test.js +14 -0
- package/cli/services/git-exec.js +37 -0
- package/cli/services/git-exec.test.js +41 -0
- package/cli/services/git-import-guard.js +28 -0
- package/cli/services/git-import-guard.test.js +18 -0
- package/cli/services/service-status.js +191 -0
- package/cli/services/service-status.test.js +39 -0
- package/cli/services/terminal-dispatch.js +135 -0
- package/cli/services/terminal-dispatch.test.js +9 -0
- package/cli/services/terminal-registry.js +39 -0
- package/cli/services/terminal-registry.test.js +31 -0
- package/cli/services/terminal-sessions.js +100 -0
- package/cli/services/terminal-sessions.test.js +26 -0
- package/cli/services/terminal-window.js +106 -0
- package/cli/services/vault-read.js +14 -0
- package/cli/services/vault-read.test.js +35 -0
- package/cli/supervisor-registry.js +116 -0
- package/cli/supervisor-registry.test.js +165 -0
- package/cli/supervisor-ui.test.js +230 -30
- package/cli/webdav-config.js +84 -0
- package/cli/webdav-config.test.js +79 -0
- package/cli/webdav-obscure.js +23 -0
- package/cli/webdav-obscure.test.js +41 -0
- package/cli/webdav-rclone-discovery.js +44 -0
- package/cli/webdav-service.js +30 -118
- package/cli/webdav-service.test.js +107 -0
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +2 -2
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
- package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
- 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
|
+
}
|