cchost 0.5.0 → 0.6.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.
@@ -0,0 +1,384 @@
1
+ 'use strict';
2
+
3
+ // `cchost ui` — a local web view over the same actions the CLI runs.
4
+ //
5
+ // Security model (the page can change real configs and delete profiles):
6
+ // - listens on 127.0.0.1 only;
7
+ // - sign-in like `rclone rcd --rc-web-gui`: a username and a per-run random
8
+ // password are printed in the terminal, and the browser is opened with a
9
+ // one-time sign-in link (single use, expires in 2 minutes). Either way
10
+ // the browser gets an HttpOnly, SameSite=Strict session cookie that
11
+ // expires (default 8h) and dies with the process;
12
+ // - password attempts are rate-limited; comparisons are constant-time;
13
+ // - the Host header must be 127.0.0.1:<port> or localhost:<port>, which
14
+ // blocks DNS-rebinding pages from reaching the server by name;
15
+ // - every API call needs the X-CCHost header (a cross-site page cannot send
16
+ // it without a CORS preflight, which is never granted); POSTs must be
17
+ // application/json and same-origin; no CORS headers are ever sent;
18
+ // - responses never contain endpoint tokens; emails are masked.
19
+
20
+ const http = require('http');
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const crypto = require('crypto');
24
+ const os = require('os');
25
+ const { spawn, execFile } = require('child_process');
26
+
27
+ const actions = require('../actions');
28
+
29
+ const MAX_BODY = 64 * 1024;
30
+ const PAGE_PATH = path.join(__dirname, 'page.html');
31
+ const LOGIN_PATH = path.join(__dirname, 'login.html');
32
+ const TOKENS_PATH = path.join(__dirname, 'tokens.css');
33
+ const COOKIE = 'cchost_session';
34
+ const CODE_TTL_MS = 2 * 60 * 1000;
35
+ const MAX_FAILURES = 5;
36
+ const LOCKOUT_MS = 60 * 1000;
37
+
38
+ // Doctor's checks are synchronous and spawn `claude --version`, which would
39
+ // block every other request for a second or more. Run them in a child Node
40
+ // process (same HOME) so previews and applies stay responsive.
41
+ function doctorInChild() {
42
+ const actionsPath = path.join(__dirname, '..', 'actions.js');
43
+ const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(actionsPath)}).doctorChecks()))`;
44
+ const home = os.homedir();
45
+ return new Promise((resolve, reject) => {
46
+ execFile(process.execPath, ['-e', script], {
47
+ env: { ...process.env, HOME: home, USERPROFILE: home },
48
+ maxBuffer: 8 * 1024 * 1024,
49
+ timeout: 60000,
50
+ }, (err, stdout) => {
51
+ if (err) return reject(err);
52
+ try {
53
+ resolve(JSON.parse(stdout));
54
+ } catch (e) {
55
+ reject(e);
56
+ }
57
+ });
58
+ });
59
+ }
60
+
61
+ function sameToken(a, b) {
62
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
63
+ const x = Buffer.from(a);
64
+ const y = Buffer.from(b);
65
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
66
+ }
67
+
68
+ function parseCookies(header) {
69
+ const out = {};
70
+ for (const part of String(header || '').split(';')) {
71
+ const i = part.indexOf('=');
72
+ if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // Readable random password, e.g. "k7Qp-3mZx-Wb9t-Lr2v".
78
+ function randomPassword() {
79
+ const alphabet = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
80
+ const bytes = crypto.randomBytes(16);
81
+ let out = '';
82
+ for (let i = 0; i < 16; i++) {
83
+ if (i && i % 4 === 0) out += '-';
84
+ out += alphabet[bytes[i] % alphabet.length];
85
+ }
86
+ return out;
87
+ }
88
+
89
+ function send(res, status, body, headers = {}) {
90
+ const isJson = typeof body !== 'string';
91
+ const payload = isJson ? JSON.stringify(body) : body;
92
+ res.writeHead(status, {
93
+ 'Content-Type': isJson ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8',
94
+ 'Cache-Control': 'no-store',
95
+ 'X-Content-Type-Options': 'nosniff',
96
+ 'Referrer-Policy': 'no-referrer',
97
+ ...headers,
98
+ });
99
+ res.end(payload);
100
+ }
101
+
102
+ function readJson(req) {
103
+ return new Promise((resolve, reject) => {
104
+ let size = 0;
105
+ const chunks = [];
106
+ req.on('data', (c) => {
107
+ size += c.length;
108
+ if (size > MAX_BODY) {
109
+ reject(Object.assign(new Error('Request too large'), { status: 413 }));
110
+ req.destroy();
111
+ return;
112
+ }
113
+ chunks.push(c);
114
+ });
115
+ req.on('end', () => {
116
+ try {
117
+ const text = Buffer.concat(chunks).toString('utf8');
118
+ resolve(text ? JSON.parse(text) : {});
119
+ } catch {
120
+ reject(Object.assign(new Error('Invalid JSON'), { status: 400 }));
121
+ }
122
+ });
123
+ req.on('error', reject);
124
+ });
125
+ }
126
+
127
+ const GROUP_LABEL = { projects: 'projects', tools: 'tools', config: 'config' };
128
+
129
+ function titleFor(action, body) {
130
+ const groups = Array.isArray(body.groups) && body.groups.length
131
+ ? body.groups.map((g) => GROUP_LABEL[g] || g).join(' + ')
132
+ : 'all resources';
133
+ switch (action) {
134
+ case 'link': return `Share ${groups} · ${body.profile}`;
135
+ case 'unlink': return `Make ${groups} private · ${body.profile}`;
136
+ case 'copy': return `Copy from ~/.claude${body.force ? ' (overwrite)' : ''} · ${body.profile}`;
137
+ case 'add': return `Add profile · ${body.name}`;
138
+ case 'remove': return `Remove profile · ${body.profile}`;
139
+ case 'doctor-fix': return body.name ? `Fix ${body.name}` : 'Apply doctor fixes';
140
+ default: return action;
141
+ }
142
+ }
143
+
144
+ function createServer({ user, pass, sessionTtlMs = 8 * 60 * 60 * 1000, log = () => {} }) {
145
+ if (!user || !pass) throw new Error('createServer needs a user and pass');
146
+ const activity = [];
147
+ const sessions = new Map(); // id -> { user, expires }
148
+ const codes = new Map(); // one-time sign-in code -> expires
149
+ let failures = [];
150
+ let lockedUntil = 0;
151
+ let nextId = 1;
152
+ let port = 0;
153
+
154
+ function newSession() {
155
+ const id = crypto.randomBytes(32).toString('hex');
156
+ const expires = Date.now() + sessionTtlMs;
157
+ sessions.set(id, { user, expires });
158
+ const cookie = `${COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(sessionTtlMs / 1000)}`;
159
+ return { cookie, expires };
160
+ }
161
+ function sessionOf(req) {
162
+ const id = parseCookies(req.headers.cookie)[COOKIE];
163
+ if (!id) return null;
164
+ const s = sessions.get(id);
165
+ if (!s) return null;
166
+ if (s.expires <= Date.now()) {
167
+ sessions.delete(id);
168
+ return null;
169
+ }
170
+ return { id, ...s };
171
+ }
172
+ function issueCode() {
173
+ const code = crypto.randomBytes(24).toString('hex');
174
+ codes.set(code, Date.now() + CODE_TTL_MS);
175
+ return code;
176
+ }
177
+ function takeCode(code) {
178
+ if (typeof code !== 'string') return false;
179
+ for (const [c, exp] of codes) {
180
+ if (exp <= Date.now()) codes.delete(c);
181
+ else if (sameToken(c, code)) {
182
+ codes.delete(c);
183
+ return true;
184
+ }
185
+ }
186
+ return false;
187
+ }
188
+
189
+ function render(file) {
190
+ const nonce = crypto.randomBytes(16).toString('base64');
191
+ const { version } = require('../../package.json');
192
+ const html = fs.readFileSync(file, 'utf8')
193
+ .replace('/*__TOKENS__*/', fs.readFileSync(TOKENS_PATH, 'utf8'))
194
+ .replace(/__NONCE__/g, nonce)
195
+ .replace(/__VERSION__/g, version);
196
+ return {
197
+ html,
198
+ csp: [
199
+ "default-src 'none'",
200
+ `script-src 'nonce-${nonce}'`,
201
+ `style-src 'nonce-${nonce}'`,
202
+ // Inline style attributes only (stagger index, small layout tweaks);
203
+ // they cannot run code.
204
+ "style-src-attr 'unsafe-inline'",
205
+ "connect-src 'self'",
206
+ "img-src 'self' data:",
207
+ "base-uri 'none'",
208
+ "form-action 'none'",
209
+ "frame-ancestors 'none'",
210
+ ].join('; '),
211
+ };
212
+ }
213
+
214
+ async function handle(req, res) {
215
+ const host = req.headers.host || '';
216
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
217
+ return send(res, 403, 'Forbidden host');
218
+ }
219
+ const url = new URL(req.url, `http://${host}`);
220
+
221
+ const htmlHeaders = (csp) => ({
222
+ 'Content-Type': 'text/html; charset=utf-8',
223
+ 'Content-Security-Policy': csp,
224
+ 'X-Frame-Options': 'DENY',
225
+ });
226
+
227
+ if (req.method === 'GET' && url.pathname === '/') {
228
+ // One-time sign-in link from the terminal: trade it for a session and
229
+ // drop the code from the address bar.
230
+ if (url.searchParams.has('code')) {
231
+ if (takeCode(url.searchParams.get('code'))) {
232
+ const { cookie } = newSession();
233
+ log('signed in with the one-time link');
234
+ return send(res, 303, '', { Location: '/', 'Set-Cookie': cookie });
235
+ }
236
+ return send(res, 303, '', { Location: '/?expired=1' });
237
+ }
238
+ const { html, csp } = render(sessionOf(req) ? PAGE_PATH : LOGIN_PATH);
239
+ return send(res, 200, html, htmlHeaders(csp));
240
+ }
241
+
242
+ if (!url.pathname.startsWith('/api/')) return send(res, 404, 'Not found');
243
+ // A custom header forces a CORS preflight for any cross-site caller, and
244
+ // preflights are never answered, so only this page can call the API.
245
+ if (req.headers['x-cchost'] !== '1') return send(res, 403, { error: 'Missing X-CCHost header' });
246
+
247
+ if (req.method === 'POST' && (url.pathname === '/api/login' || url.pathname === '/api/logout')) {
248
+ const origin = req.headers.origin;
249
+ if (origin && origin !== `http://${host}`) return send(res, 403, { error: 'Cross-origin request refused' });
250
+ if (url.pathname === '/api/logout') {
251
+ const s = sessionOf(req);
252
+ if (s) sessions.delete(s.id);
253
+ return send(res, 200, { ok: true }, { 'Set-Cookie': `${COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0` });
254
+ }
255
+ if (Date.now() < lockedUntil) {
256
+ const wait = Math.ceil((lockedUntil - Date.now()) / 1000);
257
+ return send(res, 429, { error: `Too many attempts. Try again in ${wait}s.` });
258
+ }
259
+ const body = await readJson(req);
260
+ const okUser = sameToken(String(body.user || ''), user);
261
+ const okPass = sameToken(String(body.pass || ''), pass);
262
+ if (!(okUser && okPass)) {
263
+ const now = Date.now();
264
+ failures = failures.filter((t) => now - t < LOCKOUT_MS);
265
+ failures.push(now);
266
+ if (failures.length >= MAX_FAILURES) {
267
+ lockedUntil = now + LOCKOUT_MS;
268
+ failures = [];
269
+ log('too many failed sign-ins; locked for 60s');
270
+ }
271
+ return send(res, 401, { error: 'Wrong username or password.' });
272
+ }
273
+ failures = [];
274
+ const { cookie, expires } = newSession();
275
+ log('signed in with username and password');
276
+ return send(res, 200, { ok: true, expires: new Date(expires).toISOString() }, { 'Set-Cookie': cookie });
277
+ }
278
+
279
+ const session = sessionOf(req);
280
+ if (!session) return send(res, 401, { error: 'Signed out. Sign in again.' });
281
+ if (req.method === 'GET' && url.pathname === '/api/session') {
282
+ return send(res, 200, { user: session.user, expires: new Date(session.expires).toISOString() });
283
+ }
284
+
285
+ if (req.method === 'GET') {
286
+ if (url.pathname === '/api/state') return send(res, 200, actions.state());
287
+ if (url.pathname === '/api/doctor') return send(res, 200, { checks: await doctorInChild() });
288
+ if (url.pathname === '/api/activity') return send(res, 200, { activity });
289
+ return send(res, 404, { error: 'Not found' });
290
+ }
291
+
292
+ if (req.method !== 'POST') return send(res, 405, { error: 'Method not allowed' });
293
+ const origin = req.headers.origin;
294
+ if (origin && origin !== `http://${host}`) return send(res, 403, { error: 'Cross-origin request refused' });
295
+ if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
296
+ return send(res, 415, { error: 'Expected application/json' });
297
+ }
298
+
299
+ const body = await readJson(req);
300
+ const fn = actions.ACTIONS[body.action];
301
+ if (!fn) return send(res, 400, { error: `Unknown action "${body.action}"` });
302
+
303
+ if (url.pathname === '/api/preview') {
304
+ const lines = await fn(body, { dryRun: true });
305
+ return send(res, 200, { lines });
306
+ }
307
+ if (url.pathname === '/api/apply') {
308
+ let lines;
309
+ let ok = true;
310
+ let error = null;
311
+ try {
312
+ lines = await fn(body, { dryRun: false });
313
+ ok = !lines.some((l) => l.kind === 'error');
314
+ } catch (err) {
315
+ ok = false;
316
+ error = err.message;
317
+ lines = [{ n: null, resource: null, label: null, kind: 'error', text: err.message }];
318
+ }
319
+ const entry = { id: nextId++, at: new Date().toISOString(), action: body.action, title: titleFor(body.action, body), ok, lines };
320
+ activity.unshift(entry);
321
+ if (activity.length > 200) activity.length = 200;
322
+ log(`${ok ? '✓' : '✗'} ${entry.title}`);
323
+ if (error && !lines.length) return send(res, 400, { error });
324
+ return send(res, 200, { entry, state: actions.state() });
325
+ }
326
+ return send(res, 404, { error: 'Not found' });
327
+ }
328
+
329
+ const server = http.createServer((req, res) => {
330
+ handle(req, res).catch((err) => {
331
+ const status = err.status || (err.exitCode ? 400 : 500);
332
+ send(res, status, { error: err.message || String(err) });
333
+ });
334
+ });
335
+
336
+ server.on('listening', () => { port = server.address().port; });
337
+ server.issueCode = issueCode;
338
+ return server;
339
+ }
340
+
341
+ function openBrowser(url) {
342
+ const cmd = process.platform === 'darwin' ? 'open'
343
+ : process.platform === 'win32' ? 'cmd' : 'xdg-open';
344
+ const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url];
345
+ try {
346
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
347
+ child.on('error', () => {});
348
+ child.unref();
349
+ } catch { /* the URL is printed anyway */ }
350
+ }
351
+
352
+ function formatTtl(ms) {
353
+ const m = Math.round(ms / 60000);
354
+ if (m % (60 * 24) === 0) return `${m / 60 / 24}d`;
355
+ if (m % 60 === 0) return `${m / 60}h`;
356
+ return `${m}m`;
357
+ }
358
+
359
+ function startUi({ port = 0, open = true, user = 'cchost', pass = null, sessionTtlMs } = {}) {
360
+ const password = pass || randomPassword();
361
+ const server = createServer({ user, pass: password, sessionTtlMs, log: (m) => process.stdout.write(` ${m}\n`) });
362
+ return new Promise((resolve, reject) => {
363
+ server.once('error', reject);
364
+ server.listen(port, '127.0.0.1', () => {
365
+ const base = `http://127.0.0.1:${server.address().port}/`;
366
+ const link = `${base}?code=${server.issueCode()}`;
367
+ const ttl = formatTtl(sessionTtlMs || 8 * 60 * 60 * 1000);
368
+ process.stdout.write(`cchost ui running at ${base}\n\n`);
369
+ process.stdout.write(` Username: ${user}\n`);
370
+ process.stdout.write(` Password: ${pass ? '(the one you passed with --pass)' : password}\n`);
371
+ process.stdout.write(` Sessions last ${ttl}. The password is new every run.\n\n`);
372
+ if (open) {
373
+ openBrowser(link);
374
+ process.stdout.write('Opened your browser with a one-time sign-in link (valid 2 minutes).\n');
375
+ } else {
376
+ process.stdout.write(`One-time sign-in link (valid 2 minutes):\n ${link}\n`);
377
+ }
378
+ process.stdout.write('Only this machine can reach it. Press Ctrl+C to stop.\n');
379
+ resolve({ server, url: base, link, user, password });
380
+ });
381
+ });
382
+ }
383
+
384
+ module.exports = { createServer, startUi, sameToken, randomPassword };
@@ -0,0 +1,80 @@
1
+ /* Design tokens for cchost ui. Injected into page.html and login.html by
2
+ lib/ui/server.js so both pages share one palette. */
3
+ :root {
4
+ --bg: #fafafa;
5
+ --surface: #ffffff;
6
+ --surface-2: #f4f4f5;
7
+ --surface-3: #ececee;
8
+ --border: #e4e4e7;
9
+ --border-strong: #d4d4d8;
10
+ --text: #18181b;
11
+ --text-2: #52525b;
12
+ --text-3: #6b6b73;
13
+ --primary: #2563eb;
14
+ --primary-hover: #1d4ed8;
15
+ --primary-fg: #ffffff;
16
+ --focus: #2563eb;
17
+ --shared-bg: #eff6ff;
18
+ --shared-fg: #1d4ed8;
19
+ --shared-bd: #bfdbfe;
20
+ --private-bg: #fef3c7;
21
+ --private-fg: #92400e;
22
+ --private-bd: #fde68a;
23
+ --missing-bg: transparent;
24
+ --missing-fg: #6b6b73;
25
+ --missing-bd: #d4d4d8;
26
+ --foreign-bg: #fef2f2;
27
+ --foreign-fg: #b91c1c;
28
+ --foreign-bd: #fecaca;
29
+ --ok: #15803d;
30
+ --ok-bg: #dcfce7;
31
+ --warn: #b45309;
32
+ --warn-bg: #fef3c7;
33
+ --err: #dc2626;
34
+ --err-fg: #b91c1c;
35
+ --danger-solid: #dc2626;
36
+ --err-bg: #fef2f2;
37
+ --overlay: rgba(9, 9, 11, 0.32);
38
+ --shadow-sm: 0 1px 2px rgba(9, 9, 11, 0.05);
39
+ --shadow-panel: -12px 0 32px rgba(9, 9, 11, 0.12), -1px 0 0 var(--border);
40
+ --shadow-toast: 0 8px 24px rgba(9, 9, 11, 0.2);
41
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
42
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
43
+ --ease: cubic-bezier(0.16, 1, 0.3, 1);
44
+ color-scheme: light;
45
+ }
46
+ @media (prefers-color-scheme: dark) {
47
+ :root:not([data-theme="light"]) {
48
+ --bg: #09090b; --surface: #111113; --surface-2: #18181b; --surface-3: #212125;
49
+ --border: #27272a; --border-strong: #3f3f46;
50
+ --text: #fafafa; --text-2: #a1a1aa; --text-3: #8f8f98;
51
+ --primary: #2563eb; --primary-hover: #1d4ed8; --primary-fg: #ffffff; --focus: #60a5fa;
52
+ --shared-bg: #172554; --shared-fg: #bfdbfe; --shared-bd: #1e3a8a;
53
+ --private-bg: #3a2a0a; --private-fg: #fcd34d; --private-bd: #713f12;
54
+ --missing-fg: #a1a1aa; --missing-bd: #3f3f46;
55
+ --foreign-bg: #3b0d0d; --foreign-fg: #fca5a5; --foreign-bd: #7f1d1d;
56
+ --ok: #4ade80; --ok-bg: #052e16; --warn: #fbbf24; --warn-bg: #3a2a0a;
57
+ --err: #f87171; --err-fg: #fca5a5; --err-bg: #3b0d0d;
58
+ --overlay: rgba(0, 0, 0, 0.55);
59
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
60
+ --shadow-panel: -12px 0 32px rgba(0, 0, 0, 0.5), -1px 0 0 var(--border);
61
+ color-scheme: dark;
62
+ }
63
+ }
64
+ :root[data-theme="dark"] {
65
+ --bg: #09090b; --surface: #111113; --surface-2: #18181b; --surface-3: #212125;
66
+ --border: #27272a; --border-strong: #3f3f46;
67
+ --text: #fafafa; --text-2: #a1a1aa; --text-3: #8f8f98;
68
+ --primary: #2563eb; --primary-hover: #1d4ed8; --primary-fg: #ffffff; --focus: #60a5fa;
69
+ --shared-bg: #172554; --shared-fg: #bfdbfe; --shared-bd: #1e3a8a;
70
+ --private-bg: #3a2a0a; --private-fg: #fcd34d; --private-bd: #713f12;
71
+ --missing-fg: #a1a1aa; --missing-bd: #3f3f46;
72
+ --foreign-bg: #3b0d0d; --foreign-fg: #fca5a5; --foreign-bd: #7f1d1d;
73
+ --ok: #4ade80; --ok-bg: #052e16; --warn: #fbbf24; --warn-bg: #3a2a0a;
74
+ --err: #f87171; --err-fg: #fca5a5; --err-bg: #3b0d0d;
75
+ --overlay: rgba(0, 0, 0, 0.55);
76
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
77
+ --shadow-panel: -12px 0 32px rgba(0, 0, 0, 0.5), -1px 0 0 var(--border);
78
+ color-scheme: dark;
79
+ }
80
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Multi-account profile manager for Claude Code — switch accounts, share skills/agents/projects, route to custom endpoints. Works on Windows, Linux, and macOS.",
5
5
  "keywords": [
6
6
  "claude",