@inetafrica/open-claudia 2.6.29 → 2.6.31
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/CHANGELOG.md +30 -0
- package/README.md +16 -0
- package/bin/cli.js +8 -0
- package/bin/cluster.js +75 -0
- package/core/actions.js +17 -0
- package/core/auth-flow.js +11 -0
- package/core/cluster-client.js +79 -0
- package/core/handlers.js +107 -47
- package/core/pack-guard.js +9 -2
- package/core/router.js +9 -2
- package/core/web-auth.js +160 -0
- package/package.json +1 -1
- package/web.js +189 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.6.30
|
|
4
|
+
- **Cluster self-management for AgentSpace pods.** When running as an AgentSpace pod, the bot can inspect and manage its own deployment through the backend broker — `/cluster status|logs [n]|restart|start|stop|scale <0|1>|sync` (owner-gated) for humans, and `open-claudia cluster …` for the agent. Both are thin clients over `core/cluster-client.js`, which posts to the broker's capability-gated `/pods/self/cluster` endpoint with the pod's bearer token; the bot holds no Kubernetes credentials. `brokerConfigured()` requires both `AGENTSPACE_API_URL` and `AGENTSPACE_POD_TOKEN`, so an unconfigured local install safely replies "not available" while a provisioned pod reaches the broker. Also tightens the `core/pack-guard.js` exfil heuristic: the broad "URL co-occurring with a sensitive word" rule (which false-positived on legitimate infra notes) is replaced by two precise carriers — a send/store/upload verb pointed at a URL/address, and a credential attached directly to a URL as a query/fragment param.
|
|
5
|
+
|
|
6
|
+
## v2.6.29
|
|
7
|
+
- **Kazee channel hardening.** `send()` now retries up to 3× with 1s/2s backoff (skipping 4xx), `edit()` normalizes text the same way as `send()`, inbound handling is async and detects ogg/opus voice notes (promoting them from "audio" to "voice" so the router dispatches correctly), reply-to context best-effort fetches the replied-to message before emitting the envelope, and `format.js` converts stray Telegram HTML tags (`<b>`, `<code>`, `<pre>`, `<i>`, `<s>`, `<a>`) to Markdown as a safety net.
|
|
8
|
+
|
|
9
|
+
## v2.6.28
|
|
10
|
+
- **Usage: report true peak context, not the inflated per-turn sum.** The result event re-counted the cached prefix once per tool round-trip, so `contextTokens` ballooned into the millions and the "Context this turn" alert fired every turn. It now tracks the peak single-call prefix (input + cache_read + cache_creation) and reports that as `contextTokens`; the summed value is kept as `billedContextTokens` for cost attribution.
|
|
11
|
+
|
|
12
|
+
## v2.6.27
|
|
13
|
+
- **Tasks: recency-ranked injection, staleness footer, and dream hygiene.** Open-task injection now ranks by activity (max of root and child `updatedAt`) rather than status — the most-recently-worked render in full, colder ones render title-only with a `task get <id>` pointer (new accessor + loopback endpoint + CLI sub), and anything untouched >14d (`OPEN_CLAUDIA_TASK_STALE_DAYS`) collapses into a capped one-line stale footer. The dream pass gains a task-hygiene step that surfaces the stalest tasks in the morning report — flag-and-ask only; it never edits or deletes tasks.
|
|
14
|
+
|
|
15
|
+
## v2.6.26
|
|
16
|
+
- **Memory: threat-scan guard + FTS-miss instrumentation.** `core/pack-guard.js` scans reviewer/dream content before write (override/exfil/base64 heuristics; strict on the auto-injected Stance/Procedure/State/description, lenient on Journal) and is wired into the pack reviewer and dream merge/umbrella/retag — a hit skips the write and announces one line. `logRecall` now records FTS-miss rescues (packs the judge kept that FTS scored ~0) into `recall-stats.json`, so a vector backend becomes a data-driven decision.
|
|
17
|
+
|
|
18
|
+
## v2.6.25
|
|
19
|
+
- **Memory: provenance tracking for pack writes.** A per-pack `.provenance.json` sidecar records which origin (user / reviewer / dream) last authored each section; `updatePack`/`createPack` take an origin, and an automated writer never silently overwrites a user-authored Stance. An absent sidecar defaults to "user" (protect-by-default).
|
|
20
|
+
|
|
21
|
+
## v2.6.24
|
|
22
|
+
- **Memory: pack usage telemetry + lifecycle retirement.** Packs gain `usage_count` telemetry (bumped on each use alongside `last_used`), a dream archive action retires cold packs (idle ≥30d, used ≤3×) into `.archived` with safety guards, and `pack archive`/`restore`/`archived` CLI commands are added; `/skills` surfaces the archived count. Also adds a post-dream chat summary (`DREAM_SUMMARY`), toggled with the new `/dreamsummary on|off` command (default on); when off, the nightly consolidation runs silently.
|
|
23
|
+
|
|
24
|
+
## v2.6.23
|
|
25
|
+
- **Progressive pack disclosure on by default.** Injects Stance + State teasers with on-demand Procedure/Journal and widens the pack match limit to 6. Override with `PACK_PROGRESSIVE=off`.
|
|
26
|
+
|
|
27
|
+
## v2.6.22
|
|
28
|
+
- **Wire `/skills` and `/learn` into the context-pack store.** Both previously read from / wrote to the empty legacy `~/.claude/skills` dir; now `/skills` lists packs with show/remove, and `/learn` folds work into a matching pack's Procedure plus a dated Journal line.
|
|
29
|
+
|
|
30
|
+
## v2.6.21
|
|
31
|
+
- **CI: allow tests to run without bot env.** The test suite no longer requires bot environment variables to be present.
|
|
32
|
+
|
|
3
33
|
## v2.6.20
|
|
4
34
|
- **AgentSpace pod durability.** `/upgrade` and password-change callbacks now preserve the `/api` base path expected by AgentSpace, CLI paths are resolved to concrete executables at startup, and runs repair a stale/missing session workspace before spawning Claude, Cursor, or Codex. This prevents misleading `spawn ... ENOENT` failures when the saved session cwd no longer exists.
|
|
5
35
|
|
package/README.md
CHANGED
|
@@ -47,6 +47,7 @@ Send text, voice notes, screenshots, and files from your phone. Your chosen AI a
|
|
|
47
47
|
- **Token economy** — byte-stable system prompt for maximum Anthropic prompt-cache hits; dynamic state rides each message instead
|
|
48
48
|
- **Web UI** — optional browser UI for setup and config (`open-claudia start --web`)
|
|
49
49
|
- **Auto-updates** — checks npm every 5 minutes, upgrade with `/upgrade`
|
|
50
|
+
- **Cluster self-management** — when running as an AgentSpace pod, the bot can inspect and manage its own deployment (`/cluster`, or `open-claudia cluster …` for the agent) through the broker, which authenticates, capability-checks, and audits every operation
|
|
50
51
|
- **Requirements doctor** — `/doctor` checks CLI installs, auth, voice tools, and writable paths
|
|
51
52
|
- **Cross-platform** — macOS, Linux, and Windows
|
|
52
53
|
|
|
@@ -187,6 +188,7 @@ When you select a project, the last conversation is automatically resumed. Tap "
|
|
|
187
188
|
| `/learn [<hint>]` | Capture the last piece of work into the matching context pack |
|
|
188
189
|
| `/skills [show\|remove <name>]` | List, show, or remove legacy learned skills |
|
|
189
190
|
| `/soul` | View/edit assistant identity and personality |
|
|
191
|
+
| `/dreamsummary [on\|off]` | Toggle the post-dream memory summary in chat |
|
|
190
192
|
|
|
191
193
|
### Automation
|
|
192
194
|
|
|
@@ -235,9 +237,23 @@ Tokens are redacted from chat output and logs.
|
|
|
235
237
|
| `/version` | Show current running version |
|
|
236
238
|
| `/upgrade` | Upgrade to latest version and restart (includes a post-upgrade doctor summary) |
|
|
237
239
|
| `/restart` | Restart the bot |
|
|
240
|
+
| `/dashboard` | Get a one-time login link to the web dashboard |
|
|
238
241
|
| `/stop` | Cancel a running task |
|
|
239
242
|
| `/help` | Show all commands |
|
|
240
243
|
|
|
244
|
+
### Cluster control
|
|
245
|
+
|
|
246
|
+
Available only when the bot runs as an AgentSpace-provisioned pod (the broker credentials `AGENTSPACE_API_URL` and `AGENTSPACE_POD_TOKEN` are present). Off this network the command replies that it isn't available and does nothing. Owner-gated; every operation is authenticated, capability-checked, and audited by the broker, and confined to the bot's own deployment.
|
|
247
|
+
|
|
248
|
+
| Command | Description |
|
|
249
|
+
|---------|-------------|
|
|
250
|
+
| `/cluster status` | Deployment readiness/phase |
|
|
251
|
+
| `/cluster logs [n]` | Tail the last n container log lines |
|
|
252
|
+
| `/cluster restart` | Rollout restart (pulls latest image) |
|
|
253
|
+
| `/cluster start` / `/cluster stop` | Scale to 1 / 0 replicas |
|
|
254
|
+
| `/cluster scale <0\|1>` | Stop (0) or start (1) |
|
|
255
|
+
| `/cluster sync` | Re-apply the deployment manifest |
|
|
256
|
+
|
|
241
257
|
## Memory & Long-Term Context
|
|
242
258
|
|
|
243
259
|
Open Claudia layers three memory systems on top of the backend's native sessions:
|
package/bin/cli.js
CHANGED
|
@@ -274,6 +274,11 @@ switch (command) {
|
|
|
274
274
|
break;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
case "cluster": {
|
|
278
|
+
require("./cluster").run(args.slice(1));
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
277
282
|
case "pack": {
|
|
278
283
|
require("./pack").run(args.slice(1));
|
|
279
284
|
break;
|
|
@@ -347,6 +352,9 @@ Background work (only inside an active bot-spawned task):
|
|
|
347
352
|
open-claudia task add|list|start|done|remove Per-channel todo (survives restarts)
|
|
348
353
|
open-claudia agent "<prompt>" [--role X] Spawn a throwaway sub-agent for research
|
|
349
354
|
|
|
355
|
+
Cluster control (self-management of this bot's own deployment via the broker):
|
|
356
|
+
open-claudia cluster status|logs|restart|start|stop|scale <0|1>|sync
|
|
357
|
+
|
|
350
358
|
Multi-user / cross-channel:
|
|
351
359
|
open-claudia people list|show|add|note|link Roster of known team members
|
|
352
360
|
open-claudia intros list|approve|reject Pending introductions from unknown chats
|
package/bin/cluster.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// open-claudia cluster <operation>
|
|
2
|
+
//
|
|
3
|
+
// Self-management of this bot's own Kubernetes deployment, mediated by the
|
|
4
|
+
// AgentSpace broker. The bot has no direct cluster credentials — every command
|
|
5
|
+
// here is a request the broker authenticates, capability-checks, audits, and
|
|
6
|
+
// executes against the pod's own namespace.
|
|
7
|
+
|
|
8
|
+
const { clusterRequest } = require("../core/cluster-client");
|
|
9
|
+
|
|
10
|
+
const HELP = `
|
|
11
|
+
Cluster control for this bot's own deployment (via the AgentSpace broker).
|
|
12
|
+
|
|
13
|
+
open-claudia cluster status Show deployment readiness/phase
|
|
14
|
+
open-claudia cluster logs [n] Tail the last n container log lines (default 100, max 1000)
|
|
15
|
+
open-claudia cluster restart Rollout restart (pulls latest image)
|
|
16
|
+
open-claudia cluster start Scale to 1 replica
|
|
17
|
+
open-claudia cluster stop Scale to 0 replicas
|
|
18
|
+
open-claudia cluster scale <0|1> Stop (0) or start (1)
|
|
19
|
+
open-claudia cluster sync Re-apply the deployment manifest
|
|
20
|
+
|
|
21
|
+
All operations are confined to this pod's own namespace and gated by the
|
|
22
|
+
capabilities the broker has granted this bot.
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
function ok(line) { if (line) console.log(line); process.exit(0); }
|
|
26
|
+
function fail(line) { if (line) console.error(line); process.exit(1); }
|
|
27
|
+
|
|
28
|
+
// Render a broker response into a human line + correct exit code.
|
|
29
|
+
function report(res, successLine) {
|
|
30
|
+
if (res.status >= 200 && res.status < 300) {
|
|
31
|
+
ok(typeof successLine === "function" ? successLine(res.json || {}) : successLine);
|
|
32
|
+
}
|
|
33
|
+
const detail = res.json && res.json.error ? res.json.error : (res.body || "").slice(0, 400);
|
|
34
|
+
if (res.status === 0) fail(`Broker request failed: ${detail}`);
|
|
35
|
+
fail(`Broker ${res.status}: ${detail}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function run(args) {
|
|
39
|
+
const op = (args[0] || "").toLowerCase();
|
|
40
|
+
const rest = args.slice(1);
|
|
41
|
+
if (!op || op === "help" || op === "--help") { console.log(HELP); process.exit(op ? 0 : 2); }
|
|
42
|
+
|
|
43
|
+
switch (op) {
|
|
44
|
+
case "status": {
|
|
45
|
+
const res = await clusterRequest("status");
|
|
46
|
+
return report(res, (j) =>
|
|
47
|
+
`Deployment: ${j.phase || "?"}${j.ready ? " (ready)" : ""}${j.message ? ` — ${j.message}` : ""}`);
|
|
48
|
+
}
|
|
49
|
+
case "logs": {
|
|
50
|
+
const tailLines = rest[0] ? parseInt(rest[0], 10) : undefined;
|
|
51
|
+
if (rest[0] && Number.isNaN(tailLines)) fail("Usage: cluster logs [n] (n = number of lines)");
|
|
52
|
+
const res = await clusterRequest("logs", tailLines ? { tailLines } : {});
|
|
53
|
+
return report(res, (j) => (j.logs || "(no logs)"));
|
|
54
|
+
}
|
|
55
|
+
case "restart":
|
|
56
|
+
return report(await clusterRequest("restart"), (j) => j.message || "Restart requested.");
|
|
57
|
+
case "start":
|
|
58
|
+
return report(await clusterRequest("start"), (j) => j.message || "Started.");
|
|
59
|
+
case "stop":
|
|
60
|
+
return report(await clusterRequest("stop"), (j) => j.message || "Stopped.");
|
|
61
|
+
case "scale": {
|
|
62
|
+
const n = rest[0];
|
|
63
|
+
if (n !== "0" && n !== "1") fail("Usage: cluster scale <0|1>");
|
|
64
|
+
return report(await clusterRequest("scale", { replicas: parseInt(n, 10) }), (j) => j.message || `Scaled to ${n}.`);
|
|
65
|
+
}
|
|
66
|
+
case "sync":
|
|
67
|
+
return report(await clusterRequest("sync"), (j) => j.message || "Reconciled.");
|
|
68
|
+
default:
|
|
69
|
+
console.error(`Unknown cluster operation: ${op}`);
|
|
70
|
+
console.log(HELP);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { run, HELP };
|
package/core/actions.js
CHANGED
|
@@ -19,6 +19,9 @@ const { finishOnboarding } = require("./onboarding");
|
|
|
19
19
|
const { startSession } = require("./handlers");
|
|
20
20
|
const jobs = require("./jobs");
|
|
21
21
|
const scheduler = require("./scheduler");
|
|
22
|
+
const {
|
|
23
|
+
getClaudeOAuthToken, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
|
|
24
|
+
} = require("./auth-flow");
|
|
22
25
|
|
|
23
26
|
async function handleAction(envelope) {
|
|
24
27
|
const adapter = envelope.adapter;
|
|
@@ -251,6 +254,20 @@ async function handleAction(envelope) {
|
|
|
251
254
|
state.settings.model = model;
|
|
252
255
|
saveState();
|
|
253
256
|
const beLabel = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
|
|
257
|
+
// Auth-aware: if the chosen provider isn't connected, start its login flow
|
|
258
|
+
// now. The model is already set, so once auth completes the user is on it.
|
|
259
|
+
if (be === "claude" && !getClaudeOAuthToken().value) {
|
|
260
|
+
if (!isChatOwner(envelope.channelId)) { await send("Claude isn't connected and only the owner can sign it in."); return; }
|
|
261
|
+
await send(`Set to ${model}, but Claude isn't connected yet. Starting login — open the URL I send, then paste the code here.`);
|
|
262
|
+
await runClaudeAuthCommand(["auth", "login", "--claudeai"], "Claude login");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (be === "codex" && !runCodexLoginStatus().ok) {
|
|
266
|
+
if (!isChatOwner(envelope.channelId)) { await send("Codex isn't connected and only the owner can sign it in."); return; }
|
|
267
|
+
await send(`Set to ${model}, but Codex isn't connected yet. Starting device login — open the URL and enter the code I send.`);
|
|
268
|
+
await runCodexDeviceLogin();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
254
271
|
await send(switched ? `Switched to ${beLabel}.\nModel: ${model}` : `Model: ${model}`);
|
|
255
272
|
return;
|
|
256
273
|
}
|
package/core/auth-flow.js
CHANGED
|
@@ -317,6 +317,16 @@ function runCodexLoginStatus() {
|
|
|
317
317
|
return { ...result, ok: result.ok && !isCodexAuthErrorText(result.output) };
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// Quick "is this provider usable non-interactively" check for the unified
|
|
321
|
+
// /login model picker. Claude is cheap (token presence is what the bot relies
|
|
322
|
+
// on). Codex spawns `codex login status` once — fine for a setup screen.
|
|
323
|
+
function providerAuthStatus() {
|
|
324
|
+
return {
|
|
325
|
+
claude: !!getClaudeOAuthToken().value,
|
|
326
|
+
codex: resolvedCodexPath ? runCodexLoginStatus().ok : false,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
320
330
|
async function sendCodexAuthStatusSummary(prefix = "Codex auth status") {
|
|
321
331
|
const status = runCodexLoginStatus();
|
|
322
332
|
const version = resolvedCodexPath ? runCommand(resolvedCodexPath, ["--version"]) : { ok: false, output: "not found" };
|
|
@@ -427,6 +437,7 @@ module.exports = {
|
|
|
427
437
|
clearPendingCodexAuth,
|
|
428
438
|
runCommand,
|
|
429
439
|
runCodexLoginStatus,
|
|
440
|
+
providerAuthStatus,
|
|
430
441
|
sendCodexAuthStatusSummary,
|
|
431
442
|
saveCodexApiKeyWithCli,
|
|
432
443
|
runCodexDeviceLogin,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Thin client for the AgentSpace cluster-control broker.
|
|
2
|
+
//
|
|
3
|
+
// Both entry points use this single implementation:
|
|
4
|
+
// - bin/cluster.js (the `open-claudia cluster ...` CLI, driven by the agent)
|
|
5
|
+
// - core/handlers.js (the `/cluster` Telegram slash command, driven by a human)
|
|
6
|
+
//
|
|
7
|
+
// The bot holds no Kubernetes credentials. It authenticates to the broker as
|
|
8
|
+
// itself with the pod-self token; the broker enforces capabilities, policy and
|
|
9
|
+
// audit and performs the actual cluster operation on the pod's own namespace.
|
|
10
|
+
|
|
11
|
+
const CLUSTER_OPERATIONS = ["status", "logs", "restart", "start", "stop", "scale", "sync"];
|
|
12
|
+
|
|
13
|
+
function brokerConfigured() {
|
|
14
|
+
return !!(process.env.AGENTSPACE_API_URL && process.env.AGENTSPACE_POD_TOKEN);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Build the absolute URL to a pods/self/* endpoint, mirroring the path-base
|
|
18
|
+
// logic used by the upgrade flow in core/handlers.js.
|
|
19
|
+
function endpoint(pathSuffix) {
|
|
20
|
+
const apiUrl = process.env.AGENTSPACE_API_URL;
|
|
21
|
+
const base = new URL(apiUrl);
|
|
22
|
+
if (base.pathname === "/") base.pathname = "/api/";
|
|
23
|
+
if (!base.pathname.endsWith("/")) base.pathname += "/";
|
|
24
|
+
return new URL(pathSuffix, base);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// POST a cluster operation to the broker. Resolves to { status, body, json }.
|
|
28
|
+
// Never rejects on HTTP/transport errors — surfaces them via status 0 so callers
|
|
29
|
+
// can format a single error path.
|
|
30
|
+
function clusterRequest(operation, params = {}) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
if (!brokerConfigured()) {
|
|
33
|
+
resolve({
|
|
34
|
+
status: 0,
|
|
35
|
+
body: "AgentSpace broker not configured (AGENTSPACE_API_URL / AGENTSPACE_POD_TOKEN missing). This only works inside a pod provisioned by AgentSpace.",
|
|
36
|
+
json: null,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let u;
|
|
42
|
+
try {
|
|
43
|
+
u = endpoint("pods/self/cluster");
|
|
44
|
+
} catch (e) {
|
|
45
|
+
resolve({ status: 0, body: `Bad AGENTSPACE_API_URL: ${e.message || e}`, json: null });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const payload = JSON.stringify({ operation, ...params });
|
|
50
|
+
const lib = u.protocol === "https:" ? require("https") : require("http");
|
|
51
|
+
const req = lib.request(
|
|
52
|
+
{
|
|
53
|
+
method: "POST",
|
|
54
|
+
hostname: u.hostname,
|
|
55
|
+
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
|
56
|
+
path: u.pathname + u.search,
|
|
57
|
+
headers: {
|
|
58
|
+
Authorization: `Bearer ${process.env.AGENTSPACE_POD_TOKEN}`,
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
(res) => {
|
|
64
|
+
let data = "";
|
|
65
|
+
res.on("data", (c) => { data += c; });
|
|
66
|
+
res.on("end", () => {
|
|
67
|
+
let json = null;
|
|
68
|
+
try { json = JSON.parse(data); } catch (e) {}
|
|
69
|
+
resolve({ status: res.statusCode, body: data, json });
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
req.on("error", (e) => resolve({ status: 0, body: String(e.message || e), json: null }));
|
|
74
|
+
req.setTimeout(20000, () => { req.destroy(new Error("timeout")); });
|
|
75
|
+
req.end(payload);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { clusterRequest, brokerConfigured, CLUSTER_OPERATIONS };
|
package/core/handlers.js
CHANGED
|
@@ -38,7 +38,7 @@ const {
|
|
|
38
38
|
sendClaudeAuthStatusSummary,
|
|
39
39
|
runClaudeAuthCommand, clearPendingClaudeAuth,
|
|
40
40
|
sendCodexAuthStatusSummary, runCodexDeviceLogin, clearPendingCodexAuth,
|
|
41
|
-
saveCodexApiKeyWithCli,
|
|
41
|
+
saveCodexApiKeyWithCli, providerAuthStatus,
|
|
42
42
|
} = require("./auth-flow");
|
|
43
43
|
|
|
44
44
|
const CURRENT_VERSION = require(path.join(__dirname, "..", "package.json")).version;
|
|
@@ -126,7 +126,8 @@ register({
|
|
|
126
126
|
"Identity: /whoami /link",
|
|
127
127
|
"Team: /people /intros /auth (owner)",
|
|
128
128
|
"Automation: /cron /vault /soul /dreamsummary",
|
|
129
|
-
"
|
|
129
|
+
"Connect: /login (pick model + sign in)",
|
|
130
|
+
"Claude auth: /auth_status /setup_token /use_oauth_token /clear_oauth_token",
|
|
130
131
|
"Codex auth: /codex_auth_status /codex_login /codex_setup_token",
|
|
131
132
|
"System: /doctor /requirements /restart /upgrade",
|
|
132
133
|
"",
|
|
@@ -525,6 +526,52 @@ register({
|
|
|
525
526
|
},
|
|
526
527
|
});
|
|
527
528
|
|
|
529
|
+
register({
|
|
530
|
+
name: "cluster", description: "Control this bot's own deployment (status/logs/restart/start/stop/scale/sync)",
|
|
531
|
+
args: "<status|logs|restart|start|stop|scale 0|1|sync>", ownerOnly: true,
|
|
532
|
+
handler: async (env, { tail }) => {
|
|
533
|
+
if (!ownerEnv(env)) return;
|
|
534
|
+
const { clusterRequest, brokerConfigured } = require("./cluster-client");
|
|
535
|
+
if (!brokerConfigured()) {
|
|
536
|
+
return send("Cluster control isn't available — this bot isn't running under AgentSpace (no broker credentials).");
|
|
537
|
+
}
|
|
538
|
+
const parts = (tail || "").trim().split(/\s+/).filter(Boolean);
|
|
539
|
+
const op = (parts[0] || "").toLowerCase();
|
|
540
|
+
const allowed = ["status", "logs", "restart", "start", "stop", "scale", "sync"];
|
|
541
|
+
if (!op || !allowed.includes(op)) {
|
|
542
|
+
return send("Usage: /cluster <status | logs [n] | restart | start | stop | scale 0|1 | sync>");
|
|
543
|
+
}
|
|
544
|
+
const params = {};
|
|
545
|
+
if (op === "logs" && parts[1]) {
|
|
546
|
+
const n = parseInt(parts[1], 10);
|
|
547
|
+
if (!Number.isNaN(n)) params.tailLines = n;
|
|
548
|
+
}
|
|
549
|
+
if (op === "scale") {
|
|
550
|
+
const n = parts[1];
|
|
551
|
+
if (n !== "0" && n !== "1") return send("Usage: /cluster scale <0|1>");
|
|
552
|
+
params.replicas = parseInt(n, 10);
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
const res = await clusterRequest(op, params);
|
|
556
|
+
if (res.status >= 200 && res.status < 300) {
|
|
557
|
+
const j = res.json || {};
|
|
558
|
+
if (op === "status") {
|
|
559
|
+
return send(`Deployment: ${j.phase || "?"}${j.ready ? " (ready)" : ""}${j.message ? ` — ${j.message}` : ""}`);
|
|
560
|
+
}
|
|
561
|
+
if (op === "logs") {
|
|
562
|
+
const logs = j.logs || "(no logs)";
|
|
563
|
+
return send(logs.length > 3500 ? logs.slice(-3500) : logs);
|
|
564
|
+
}
|
|
565
|
+
return send(j.message || "Done.");
|
|
566
|
+
}
|
|
567
|
+
const detail = (res.json && res.json.error) ? res.json.error : (res.body || "").slice(0, 300);
|
|
568
|
+
return send(res.status === 0 ? `Cluster request failed: ${detail}` : `Cluster ${res.status}: ${detail}`);
|
|
569
|
+
} catch (e) {
|
|
570
|
+
return send(`Cluster error: ${e.message || e}`);
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
|
|
528
575
|
register({
|
|
529
576
|
name: "projects", description: "Browse all workspace projects",
|
|
530
577
|
handler: async (env) => { if (authorized(env)) send("Pick:", { keyboard: projectKeyboard() }); },
|
|
@@ -559,6 +606,57 @@ register({
|
|
|
559
606
|
},
|
|
560
607
|
});
|
|
561
608
|
|
|
609
|
+
// Shared model picker used by /model and the unified /login. When authAware is
|
|
610
|
+
// set, the provider section headers show connection status so the user knows a
|
|
611
|
+
// tap on an unconnected provider will start its auth flow (handled in the mb:
|
|
612
|
+
// callback in actions.js).
|
|
613
|
+
function buildModelPicker({ authAware = false } = {}) {
|
|
614
|
+
const { settings } = currentState();
|
|
615
|
+
const auth = authAware ? providerAuthStatus() : null;
|
|
616
|
+
const mark = (ok) => authAware ? (ok ? " · connected" : " · tap to connect") : "";
|
|
617
|
+
const rows = [];
|
|
618
|
+
rows.push([{ text: `── Claude${mark(auth && auth.claude)} ──`, callback_data: "noop" }]);
|
|
619
|
+
rows.push([
|
|
620
|
+
{ text: "Fable 5", callback_data: "mb:claude:claude-fable-5" },
|
|
621
|
+
{ text: "Opus 4.8", callback_data: "mb:claude:claude-opus-4-8" },
|
|
622
|
+
]);
|
|
623
|
+
rows.push([
|
|
624
|
+
{ text: "Opus 4.7", callback_data: "mb:claude:claude-opus-4-7" },
|
|
625
|
+
{ text: "Opus 4.6", callback_data: "mb:claude:claude-opus-4-6" },
|
|
626
|
+
]);
|
|
627
|
+
rows.push([
|
|
628
|
+
{ text: "Sonnet 4.6", callback_data: "mb:claude:claude-sonnet-4-6" },
|
|
629
|
+
{ text: "Haiku", callback_data: "mb:claude:claude-haiku-4-5-20251001" },
|
|
630
|
+
]);
|
|
631
|
+
if (resolvedCursorPath) {
|
|
632
|
+
rows.push([{ text: "── Cursor ──", callback_data: "noop" }]);
|
|
633
|
+
rows.push([
|
|
634
|
+
{ text: "Composer 2", callback_data: "mb:cursor:composer-2" },
|
|
635
|
+
{ text: "Composer 2 Fast", callback_data: "mb:cursor:composer-2-fast" },
|
|
636
|
+
{ text: "Auto", callback_data: "mb:cursor:auto" },
|
|
637
|
+
]);
|
|
638
|
+
rows.push([
|
|
639
|
+
{ text: "Opus 4.6 Thinking", callback_data: "mb:cursor:claude-4.6-opus-high-thinking" },
|
|
640
|
+
{ text: "GPT-5.4", callback_data: "mb:cursor:gpt-5.4-medium" },
|
|
641
|
+
]);
|
|
642
|
+
}
|
|
643
|
+
if (resolvedCodexPath) {
|
|
644
|
+
rows.push([{ text: `── Codex${mark(auth && auth.codex)} ──`, callback_data: "noop" }]);
|
|
645
|
+
rows.push([
|
|
646
|
+
{ text: "GPT-5.5", callback_data: "mb:codex:gpt-5.5" },
|
|
647
|
+
{ text: "gpt-5", callback_data: "mb:codex:gpt-5" },
|
|
648
|
+
{ text: "gpt-5-codex", callback_data: "mb:codex:gpt-5-codex" },
|
|
649
|
+
]);
|
|
650
|
+
rows.push([
|
|
651
|
+
{ text: "o3", callback_data: "mb:codex:o3" },
|
|
652
|
+
{ text: "o4-mini", callback_data: "mb:codex:o4-mini" },
|
|
653
|
+
]);
|
|
654
|
+
}
|
|
655
|
+
rows.push([{ text: `Default (Claude: ${DEFAULT_CLAUDE_MODEL})`, callback_data: "m:default" }]);
|
|
656
|
+
const beLabel = settings.backend === "cursor" ? "Cursor" : settings.backend === "codex" ? "Codex" : "Claude";
|
|
657
|
+
return { rows, beLabel, model: settings.model };
|
|
658
|
+
}
|
|
659
|
+
|
|
562
660
|
register({
|
|
563
661
|
name: "model", description: "Switch model (opus/sonnet/haiku)", args: "[<model>]",
|
|
564
662
|
handler: async (env, { tail }) => {
|
|
@@ -569,48 +667,8 @@ register({
|
|
|
569
667
|
if (settings.model === "default") settings.model = null;
|
|
570
668
|
return send(`Model: ${settings.model || "default"}`);
|
|
571
669
|
}
|
|
572
|
-
const {
|
|
573
|
-
|
|
574
|
-
rows.push([{ text: "── Claude ──", callback_data: "noop" }]);
|
|
575
|
-
rows.push([
|
|
576
|
-
{ text: "Fable 5", callback_data: "mb:claude:claude-fable-5" },
|
|
577
|
-
{ text: "Opus 4.8", callback_data: "mb:claude:claude-opus-4-8" },
|
|
578
|
-
]);
|
|
579
|
-
rows.push([
|
|
580
|
-
{ text: "Opus 4.7", callback_data: "mb:claude:claude-opus-4-7" },
|
|
581
|
-
{ text: "Opus 4.6", callback_data: "mb:claude:claude-opus-4-6" },
|
|
582
|
-
]);
|
|
583
|
-
rows.push([
|
|
584
|
-
{ text: "Sonnet 4.6", callback_data: "mb:claude:claude-sonnet-4-6" },
|
|
585
|
-
{ text: "Haiku", callback_data: "mb:claude:claude-haiku-4-5-20251001" },
|
|
586
|
-
]);
|
|
587
|
-
if (resolvedCursorPath) {
|
|
588
|
-
rows.push([{ text: "── Cursor ──", callback_data: "noop" }]);
|
|
589
|
-
rows.push([
|
|
590
|
-
{ text: "Composer 2", callback_data: "mb:cursor:composer-2" },
|
|
591
|
-
{ text: "Composer 2 Fast", callback_data: "mb:cursor:composer-2-fast" },
|
|
592
|
-
{ text: "Auto", callback_data: "mb:cursor:auto" },
|
|
593
|
-
]);
|
|
594
|
-
rows.push([
|
|
595
|
-
{ text: "Opus 4.6 Thinking", callback_data: "mb:cursor:claude-4.6-opus-high-thinking" },
|
|
596
|
-
{ text: "GPT-5.4", callback_data: "mb:cursor:gpt-5.4-medium" },
|
|
597
|
-
]);
|
|
598
|
-
}
|
|
599
|
-
if (resolvedCodexPath) {
|
|
600
|
-
rows.push([{ text: "── Codex ──", callback_data: "noop" }]);
|
|
601
|
-
rows.push([
|
|
602
|
-
{ text: "GPT-5.5", callback_data: "mb:codex:gpt-5.5" },
|
|
603
|
-
{ text: "gpt-5", callback_data: "mb:codex:gpt-5" },
|
|
604
|
-
{ text: "gpt-5-codex", callback_data: "mb:codex:gpt-5-codex" },
|
|
605
|
-
]);
|
|
606
|
-
rows.push([
|
|
607
|
-
{ text: "o3", callback_data: "mb:codex:o3" },
|
|
608
|
-
{ text: "o4-mini", callback_data: "mb:codex:o4-mini" },
|
|
609
|
-
]);
|
|
610
|
-
}
|
|
611
|
-
rows.push([{ text: `Default (Claude: ${DEFAULT_CLAUDE_MODEL})`, callback_data: "m:default" }]);
|
|
612
|
-
const beLabel = settings.backend === "cursor" ? "Cursor" : settings.backend === "codex" ? "Codex" : "Claude";
|
|
613
|
-
send(`Current: ${beLabel} · ${settings.model || "default"}\n\nPick a model — backend switches automatically.\nOr type /model <name>.`, { keyboard: { inline_keyboard: rows } });
|
|
670
|
+
const { rows, beLabel, model } = buildModelPicker();
|
|
671
|
+
send(`Current: ${beLabel} · ${model || "default"}\n\nPick a model — backend switches automatically.\nOr type /model <name>.`, { keyboard: { inline_keyboard: rows } });
|
|
614
672
|
},
|
|
615
673
|
});
|
|
616
674
|
|
|
@@ -1102,10 +1160,12 @@ register({
|
|
|
1102
1160
|
});
|
|
1103
1161
|
|
|
1104
1162
|
register({
|
|
1105
|
-
name: "login", description: "
|
|
1163
|
+
name: "login", description: "Pick a model and connect its provider", ownerOnly: true,
|
|
1106
1164
|
handler: async (env) => {
|
|
1107
|
-
if (!ownerEnv(env)) return send("Owner only —
|
|
1108
|
-
await
|
|
1165
|
+
if (!ownerEnv(env)) return send("Owner only — provider auth is shared across users.");
|
|
1166
|
+
await send("Connecting… checking which providers are signed in.");
|
|
1167
|
+
const { rows, beLabel, model } = buildModelPicker({ authAware: true });
|
|
1168
|
+
send(`Current: ${beLabel} · ${model || "default"}\n\nPick a model. If its provider isn't connected yet, tapping it starts the login flow — then you're on that model.`, { keyboard: { inline_keyboard: rows } });
|
|
1109
1169
|
},
|
|
1110
1170
|
});
|
|
1111
1171
|
|
package/core/pack-guard.js
CHANGED
|
@@ -29,8 +29,15 @@ const OVERRIDE_PATTERNS = [
|
|
|
29
29
|
// Exfiltration: a send/post/upload verb pointed at a URL or address. Scanned in
|
|
30
30
|
// every section, including Journal.
|
|
31
31
|
const EXFIL_PATTERNS = [
|
|
32
|
-
|
|
33
|
-
/\b(
|
|
32
|
+
// A send/store/upload verb pointed at a URL or address.
|
|
33
|
+
/\b(send|post|upload|exfiltrate|forward|transmit|leak|email|curl|wget|fetch|store|save|drop|put|copy)\b[^.\n]{0,60}\b(to|at|into)\b[^.\n]{0,40}(https?:\/\/|ftp:\/\/|[\w.-]+@[\w.-]+)/i,
|
|
34
|
+
// A credential embedded directly in a URL as a query/fragment param
|
|
35
|
+
// (e.g. https://evil/?token=…): the classic verbless exfil carrier. Prose that
|
|
36
|
+
// merely mentions a URL near the word "token" no longer trips — the credential
|
|
37
|
+
// must actually be attached to the URL with =/:, which legitimate infra/ops
|
|
38
|
+
// notes don't do. This replaces a broad URL+keyword co-occurrence rule that
|
|
39
|
+
// false-positived on benign sentences like "the API at <url> uses a token".
|
|
40
|
+
/(https?:\/\/)[^\s)]*?\b(api_?key|access_?token|token|password|secret|credential)\b\s*[=:]/i,
|
|
34
41
|
/\bcurl\b[^\n]*\b(https?:\/\/)/i,
|
|
35
42
|
];
|
|
36
43
|
|
package/core/router.js
CHANGED
|
@@ -228,10 +228,17 @@ async function handleText(envelope) {
|
|
|
228
228
|
if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel !== "manual OAuth token save") {
|
|
229
229
|
const text = (envelope.text || "").trim();
|
|
230
230
|
if (looksLikeClaudeAuthReply(text)) {
|
|
231
|
-
await
|
|
231
|
+
await deleteMessage(envelope.messageId);
|
|
232
|
+
try {
|
|
233
|
+
state.pendingClaudeAuthProcess.stdin.write(text + "\n");
|
|
234
|
+
await send("Got it — sent the code to Claude. I'll confirm with an auth-status check when it finishes.");
|
|
235
|
+
} catch (e) {
|
|
236
|
+
clearPendingClaudeAuth(state);
|
|
237
|
+
await send(`Could not send the auth code to Claude: ${redactSensitive(e.message)}`);
|
|
238
|
+
}
|
|
232
239
|
return;
|
|
233
240
|
}
|
|
234
|
-
await send("Claude login is still waiting.
|
|
241
|
+
await send("Claude login is still waiting. Paste the code/callback Claude gave you and I'll submit it, or send /cancel_auth to stop.");
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
if (!isOnboarded() && state.onboardingStep) {
|
package/core/web-auth.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Web-driven Claude/Codex authentication. Runs the same interactive CLIs
|
|
2
|
+
// the Telegram /login flow uses, but captures the URL/output into in-memory
|
|
3
|
+
// state that the web dashboard polls, instead of pushing it to a chat.
|
|
4
|
+
// A single admin owns the dashboard, so one module-level slot per provider
|
|
5
|
+
// is enough. Lives in-process with the bot (web.js is started by bot.js).
|
|
6
|
+
|
|
7
|
+
const { spawn } = require("child_process");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const { CLAUDE_PATH, resolvedCodexPath, botSubprocessEnv } = require("./config");
|
|
10
|
+
const { redactSensitive, stripTerminalControls, extractUrls } = require("./redact");
|
|
11
|
+
const {
|
|
12
|
+
claudeSubprocessEnv, saveClaudeOAuthToken, getClaudeOAuthToken,
|
|
13
|
+
extractClaudeToken, isClaudeAuthUrl, looksLikeClaudeToken, looksLikeOpenAIKey,
|
|
14
|
+
runClaudeAuthStatusDiagnostic, isClaudeAuthErrorText,
|
|
15
|
+
runCodexLoginStatus, saveCodexApiKeyWithCli,
|
|
16
|
+
} = require("./auth-flow");
|
|
17
|
+
|
|
18
|
+
const HOME = process.env.HOME || os.homedir();
|
|
19
|
+
|
|
20
|
+
// ── Claude interactive browser login ────────────────────────────────
|
|
21
|
+
let claude = null;
|
|
22
|
+
|
|
23
|
+
function startClaudeLogin() {
|
|
24
|
+
if (claude && claude.proc && !claude.done) return { ok: true, already: true };
|
|
25
|
+
const proc = spawn(CLAUDE_PATH, ["auth", "login", "--claudeai"], {
|
|
26
|
+
cwd: HOME, env: claudeSubprocessEnv(), stdio: ["pipe", "pipe", "pipe"],
|
|
27
|
+
});
|
|
28
|
+
claude = { proc, url: null, log: "", awaitingCode: false, done: false, ok: false, error: null, tokenStored: false };
|
|
29
|
+
const onChunk = (buf) => {
|
|
30
|
+
const chunk = buf.toString();
|
|
31
|
+
claude.log = (claude.log + chunk).slice(-4000);
|
|
32
|
+
const token = extractClaudeToken(chunk) || extractClaudeToken(claude.log);
|
|
33
|
+
if (token && !claude.tokenStored) claude.tokenStored = saveClaudeOAuthToken(token);
|
|
34
|
+
for (const u of extractUrls(chunk)) {
|
|
35
|
+
if (isClaudeAuthUrl(u) && !claude.url) { claude.url = u; claude.awaitingCode = true; }
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
proc.stdout.on("data", onChunk);
|
|
39
|
+
proc.stderr.on("data", onChunk);
|
|
40
|
+
proc.on("close", (code) => {
|
|
41
|
+
claude.done = true;
|
|
42
|
+
claude.awaitingCode = false;
|
|
43
|
+
const tty = /raw mode is not supported|process\.stdin|not a tty|inappropriate ioctl/i.test(claude.log);
|
|
44
|
+
claude.ok = claude.tokenStored || code === 0;
|
|
45
|
+
if (!claude.ok) claude.error = tty ? "tty" : "failed";
|
|
46
|
+
});
|
|
47
|
+
proc.on("error", (err) => { claude.done = true; claude.ok = false; claude.error = redactSensitive(err.message); });
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function submitClaudeCode(code) {
|
|
52
|
+
if (!claude || !claude.proc || claude.done) return { ok: false, error: "No Claude login is in progress." };
|
|
53
|
+
try {
|
|
54
|
+
claude.proc.stdin.write(String(code || "").trim() + "\n");
|
|
55
|
+
claude.awaitingCode = false;
|
|
56
|
+
return { ok: true };
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { ok: false, error: redactSensitive(e.message) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cancelClaude() {
|
|
63
|
+
if (claude && claude.proc && claude.proc.kill) { try { claude.proc.kill("SIGTERM"); } catch (e) {} }
|
|
64
|
+
claude = null;
|
|
65
|
+
return { ok: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function claudeState() {
|
|
69
|
+
if (!claude) return { running: false };
|
|
70
|
+
return {
|
|
71
|
+
running: !claude.done,
|
|
72
|
+
url: claude.url,
|
|
73
|
+
awaitingCode: claude.awaitingCode,
|
|
74
|
+
done: claude.done,
|
|
75
|
+
ok: claude.ok,
|
|
76
|
+
error: claude.error,
|
|
77
|
+
log: redactSensitive(stripTerminalControls(claude.log)).slice(-600),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Reliable path: save a pasted Claude OAuth token directly.
|
|
82
|
+
function saveClaudeToken(token) {
|
|
83
|
+
const t = String(token || "").trim();
|
|
84
|
+
if (!looksLikeClaudeToken(t)) return { ok: false, error: "That does not look like a Claude OAuth token." };
|
|
85
|
+
return saveClaudeOAuthToken(t) ? { ok: true } : { ok: false, error: "Could not store the token." };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Codex device login ──────────────────────────────────────────────
|
|
89
|
+
let codex = null;
|
|
90
|
+
|
|
91
|
+
function startCodexLogin() {
|
|
92
|
+
if (!resolvedCodexPath) return { ok: false, error: "Codex CLI not found." };
|
|
93
|
+
if (codex && codex.proc && !codex.done) return { ok: true, already: true };
|
|
94
|
+
const proc = spawn(resolvedCodexPath, ["login", "--device-auth"], {
|
|
95
|
+
cwd: HOME, env: botSubprocessEnv(), stdio: ["pipe", "pipe", "pipe"],
|
|
96
|
+
});
|
|
97
|
+
codex = { proc, url: null, code: null, log: "", done: false, ok: false, error: null };
|
|
98
|
+
const onChunk = (buf) => {
|
|
99
|
+
const clean = redactSensitive(stripTerminalControls(buf.toString()));
|
|
100
|
+
codex.log = (codex.log + clean).slice(-4000);
|
|
101
|
+
for (const u of extractUrls(clean)) { if (!codex.url) codex.url = u; }
|
|
102
|
+
const m = clean.match(/(?:code|device code)[:\s]+([A-Z0-9-]{6,})/i);
|
|
103
|
+
if (m && !codex.code) codex.code = m[1];
|
|
104
|
+
};
|
|
105
|
+
proc.stdout.on("data", onChunk);
|
|
106
|
+
proc.stderr.on("data", onChunk);
|
|
107
|
+
proc.on("close", (code) => {
|
|
108
|
+
codex.done = true;
|
|
109
|
+
const tty = /raw mode is not supported|not a tty|inappropriate ioctl/i.test(codex.log);
|
|
110
|
+
codex.ok = code === 0;
|
|
111
|
+
if (!codex.ok) codex.error = tty ? "tty" : "failed";
|
|
112
|
+
});
|
|
113
|
+
proc.on("error", (err) => { codex.done = true; codex.ok = false; codex.error = redactSensitive(err.message); });
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function cancelCodex() {
|
|
118
|
+
if (codex && codex.proc && codex.proc.kill) { try { codex.proc.kill("SIGTERM"); } catch (e) {} }
|
|
119
|
+
codex = null;
|
|
120
|
+
return { ok: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function codexState() {
|
|
124
|
+
if (!codex) return { running: false };
|
|
125
|
+
return {
|
|
126
|
+
running: !codex.done,
|
|
127
|
+
url: codex.url,
|
|
128
|
+
code: codex.code,
|
|
129
|
+
done: codex.done,
|
|
130
|
+
ok: codex.ok,
|
|
131
|
+
error: codex.error,
|
|
132
|
+
log: codex.log.slice(-600),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Reliable path: save a pasted OpenAI API key via the Codex CLI.
|
|
137
|
+
async function saveCodexApiKey(key) {
|
|
138
|
+
if (!resolvedCodexPath) return { ok: false, error: "Codex CLI not found." };
|
|
139
|
+
const k = String(key || "").trim();
|
|
140
|
+
if (!looksLikeOpenAIKey(k)) return { ok: false, error: "That does not look like an OpenAI API key." };
|
|
141
|
+
const result = await saveCodexApiKeyWithCli(k);
|
|
142
|
+
return result.ok ? { ok: true } : { ok: false, error: redactSensitive(result.output).slice(-300) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Combined status ─────────────────────────────────────────────────
|
|
146
|
+
function authStatus() {
|
|
147
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
148
|
+
const diag = runClaudeAuthStatusDiagnostic();
|
|
149
|
+
const codexStatus = resolvedCodexPath ? runCodexLoginStatus() : { ok: false };
|
|
150
|
+
return {
|
|
151
|
+
claude: { configured: !!tokenInfo.value, source: tokenInfo.source, loggedIn: !isClaudeAuthErrorText(diag) },
|
|
152
|
+
codex: { cliFound: !!resolvedCodexPath, loggedIn: !!codexStatus.ok },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
startClaudeLogin, submitClaudeCode, cancelClaude, claudeState, saveClaudeToken,
|
|
158
|
+
startCodexLogin, cancelCodex, codexState, saveCodexApiKey,
|
|
159
|
+
authStatus,
|
|
160
|
+
};
|
package/package.json
CHANGED
package/web.js
CHANGED
|
@@ -15,6 +15,7 @@ const ENV_FILE = path.join(CONFIG_DIR, ".env");
|
|
|
15
15
|
const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
|
|
16
16
|
const SOUL_FILE = path.join(CONFIG_DIR, "soul.md");
|
|
17
17
|
const CRONS_FILE = path.join(CONFIG_DIR, "crons.json");
|
|
18
|
+
const JOBS_FILE = path.join(CONFIG_DIR, "jobs.json");
|
|
18
19
|
const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
|
|
19
20
|
const WEB_PASSWORD_FILE = path.join(CONFIG_DIR, ".web-password");
|
|
20
21
|
const PORT = parseInt(process.env.WEB_PORT || "8080", 10);
|
|
@@ -173,6 +174,13 @@ function loadSoul() {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
function loadCrons() {
|
|
177
|
+
// Crons now live in the unified jobs store (jobs.json), tagged kind:"cron".
|
|
178
|
+
// The legacy crons.json was migrated and renamed, so read jobs first and
|
|
179
|
+
// only fall back to the old file for un-migrated installs.
|
|
180
|
+
try {
|
|
181
|
+
const jobs = JSON.parse(fs.readFileSync(JOBS_FILE, "utf-8"));
|
|
182
|
+
if (Array.isArray(jobs)) return jobs.filter((j) => j && j.kind === "cron");
|
|
183
|
+
} catch (e) { /* fall through to legacy */ }
|
|
176
184
|
try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); } catch (e) { return []; }
|
|
177
185
|
}
|
|
178
186
|
|
|
@@ -261,6 +269,24 @@ async function handleAPI(req, res, body) {
|
|
|
261
269
|
return res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
262
270
|
}
|
|
263
271
|
|
|
272
|
+
// ── Authentication (Claude / Codex) ──
|
|
273
|
+
if (url.startsWith("/api/auth/")) {
|
|
274
|
+
const webAuth = require("./core/web-auth");
|
|
275
|
+
const json = (obj, c = 200) => { res.writeHead(c, { "Content-Type": "application/json" }); return res.end(JSON.stringify(obj)); };
|
|
276
|
+
const post = req.method === "POST";
|
|
277
|
+
if (url === "/api/auth/status") return json(webAuth.authStatus());
|
|
278
|
+
if (url === "/api/auth/claude/start" && post) return json(webAuth.startClaudeLogin());
|
|
279
|
+
if (url === "/api/auth/claude/state") return json(webAuth.claudeState());
|
|
280
|
+
if (url === "/api/auth/claude/code" && post) return json(webAuth.submitClaudeCode(JSON.parse(body || "{}").code));
|
|
281
|
+
if (url === "/api/auth/claude/token" && post) return json(webAuth.saveClaudeToken(JSON.parse(body || "{}").token));
|
|
282
|
+
if (url === "/api/auth/claude/cancel" && post) return json(webAuth.cancelClaude());
|
|
283
|
+
if (url === "/api/auth/codex/start" && post) return json(webAuth.startCodexLogin());
|
|
284
|
+
if (url === "/api/auth/codex/state") return json(webAuth.codexState());
|
|
285
|
+
if (url === "/api/auth/codex/key" && post) return json(await webAuth.saveCodexApiKey(JSON.parse(body || "{}").key));
|
|
286
|
+
if (url === "/api/auth/codex/cancel" && post) return json(webAuth.cancelCodex());
|
|
287
|
+
return json({ error: "Unknown auth route" }, 404);
|
|
288
|
+
}
|
|
289
|
+
|
|
264
290
|
// Status
|
|
265
291
|
if (url === "/api/status") {
|
|
266
292
|
const env = loadEnv();
|
|
@@ -489,13 +515,30 @@ async function handleAPI(req, res, body) {
|
|
|
489
515
|
|
|
490
516
|
// ── HTML UI ────────────────────────────────────────────────────────
|
|
491
517
|
|
|
518
|
+
// The dashboard is generic "Open Claudia" by default, but each pod is its
|
|
519
|
+
// own named bot (e.g. euvy). Prefer an explicit BOT_NAME, else derive the
|
|
520
|
+
// pod name from its public hostname, else fall back to the generic brand.
|
|
521
|
+
function botDisplayName() {
|
|
522
|
+
if (process.env.BOT_NAME && process.env.BOT_NAME.trim()) return process.env.BOT_NAME.trim();
|
|
523
|
+
try {
|
|
524
|
+
const u = process.env.WEB_PUBLIC_URL;
|
|
525
|
+
if (u) {
|
|
526
|
+
const host = new URL(u).hostname;
|
|
527
|
+
const label = host.split(".")[0];
|
|
528
|
+
if (label) return label;
|
|
529
|
+
}
|
|
530
|
+
} catch (e) { /* ignore */ }
|
|
531
|
+
return "Open Claudia";
|
|
532
|
+
}
|
|
533
|
+
|
|
492
534
|
function getHTML() {
|
|
535
|
+
const botName = botDisplayName();
|
|
493
536
|
return `<!DOCTYPE html>
|
|
494
537
|
<html lang="en">
|
|
495
538
|
<head>
|
|
496
539
|
<meta charset="UTF-8">
|
|
497
540
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
498
|
-
<title
|
|
541
|
+
<title>${botName}</title>
|
|
499
542
|
<style>
|
|
500
543
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
501
544
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f0f0f; color: #e0e0e0; min-height: 100vh; }
|
|
@@ -570,7 +613,7 @@ async function init() {
|
|
|
570
613
|
function showLogin() {
|
|
571
614
|
$("#app").innerHTML = \`
|
|
572
615
|
<div class="login-page"><div class="login-box">
|
|
573
|
-
<h1
|
|
616
|
+
<h1>${botName}</h1>
|
|
574
617
|
<p class="subtitle">Enter your admin password</p>
|
|
575
618
|
<div id="login-msg"></div>
|
|
576
619
|
<div class="card">
|
|
@@ -625,6 +668,7 @@ function render() {
|
|
|
625
668
|
{ id: "dashboard", label: "Dashboard" },
|
|
626
669
|
{ id: "usage", label: "Usage" },
|
|
627
670
|
{ id: "auth", label: "Users" },
|
|
671
|
+
{ id: "credentials", label: "Credentials" },
|
|
628
672
|
{ id: "soul", label: "Soul" },
|
|
629
673
|
{ id: "crons", label: "Crons" },
|
|
630
674
|
{ id: "sessions", label: "Sessions" },
|
|
@@ -637,7 +681,7 @@ function render() {
|
|
|
637
681
|
}
|
|
638
682
|
$("#app").innerHTML = \`
|
|
639
683
|
<div class="container">
|
|
640
|
-
<h1
|
|
684
|
+
<h1>${botName}</h1>
|
|
641
685
|
<p class="subtitle">v\${status.version}</p>
|
|
642
686
|
<div class="tabs">
|
|
643
687
|
\${tabs.map(t => \`<div class="tab \${currentTab===t.id?'active':''}" onclick="switchTab('\${t.id}')">\${t.label}</div>\`).join("")}
|
|
@@ -724,6 +768,34 @@ async function loadTab() {
|
|
|
724
768
|
</div>\`).join("")}
|
|
725
769
|
</div>\` : ""}\`;
|
|
726
770
|
}
|
|
771
|
+
else if (currentTab === "credentials") {
|
|
772
|
+
el.innerHTML = \`
|
|
773
|
+
<div class="card" id="cred-status"><h2>Credentials</h2><p class="subtitle">Loading status…</p></div>
|
|
774
|
+
<div class="card">
|
|
775
|
+
<h2>Claude</h2>
|
|
776
|
+
<button onclick="startClaudeAuth()">Authenticate via browser</button>
|
|
777
|
+
<div id="claude-flow" style="margin-top:12px;"></div>
|
|
778
|
+
<div style="margin-top:16px;border-top:1px solid #2a2a2a;padding-top:16px;">
|
|
779
|
+
<label>Or paste a Claude OAuth token</label>
|
|
780
|
+
<div style="color:#777;font-size:11px;margin:2px 0 6px;">Generate with <code>claude setup-token</code> in a terminal if the browser flow can't finish here.</div>
|
|
781
|
+
<input id="claude-token" type="password" placeholder="sk-ant-...">
|
|
782
|
+
<button onclick="saveClaudeTokenWeb()" style="margin-top:8px;">Save token</button>
|
|
783
|
+
<span id="claude-token-msg" style="margin-left:10px;font-size:12px;color:#888;"></span>
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
<div class="card">
|
|
787
|
+
<h2>Codex</h2>
|
|
788
|
+
<button onclick="startCodexAuth()">Authenticate via device</button>
|
|
789
|
+
<div id="codex-flow" style="margin-top:12px;"></div>
|
|
790
|
+
<div style="margin-top:16px;border-top:1px solid #2a2a2a;padding-top:16px;">
|
|
791
|
+
<label>Or paste an OpenAI API key</label>
|
|
792
|
+
<input id="codex-key" type="password" placeholder="sk-...">
|
|
793
|
+
<button onclick="saveCodexKeyWeb()" style="margin-top:8px;">Save key</button>
|
|
794
|
+
<span id="codex-key-msg" style="margin-left:10px;font-size:12px;color:#888;"></span>
|
|
795
|
+
</div>
|
|
796
|
+
</div>\`;
|
|
797
|
+
loadCredStatus();
|
|
798
|
+
}
|
|
727
799
|
else if (currentTab === "soul") {
|
|
728
800
|
const soul = await api("/api/soul");
|
|
729
801
|
if (!soul) return;
|
|
@@ -789,23 +861,44 @@ async function loadTab() {
|
|
|
789
861
|
</div>
|
|
790
862
|
<div class="card">
|
|
791
863
|
<h2>Configuration</h2>
|
|
792
|
-
<p style="color:#888;font-size:13px;margin-bottom:
|
|
864
|
+
<p style="color:#888;font-size:13px;margin-bottom:16px;">Changes are saved to the bot's environment and take effect after the next restart.</p>
|
|
793
865
|
<div id="config-fields"></div>
|
|
794
866
|
<button onclick="saveConfig()">Save</button>
|
|
795
867
|
<span id="config-msg" style="margin-left:12px;font-size:13px;color:#888;"></span>
|
|
796
868
|
</div>\`;
|
|
797
869
|
const config = await api("/api/config");
|
|
798
870
|
if (!config) return;
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
"
|
|
871
|
+
const groups = [
|
|
872
|
+
{ title: "General", fields: [
|
|
873
|
+
{ k: "WORKSPACE", label: "Workspace folder", desc: "Root directory where the bot reads and writes project files.", ph: "/data/Workspace" },
|
|
874
|
+
{ k: "CLAUDE_PATH", label: "Claude CLI path", desc: "Path or command name for the Claude Code CLI.", ph: "claude" },
|
|
875
|
+
]},
|
|
876
|
+
{ title: "Voice transcription", fields: [
|
|
877
|
+
{ k: "WHISPER_CLI", label: "Whisper CLI", desc: "Path to the whisper binary for transcribing voice notes. Leave blank to disable voice.", ph: "whisper" },
|
|
878
|
+
{ k: "FFMPEG", label: "ffmpeg path", desc: "Path to ffmpeg, used to convert incoming voice audio.", ph: "ffmpeg" },
|
|
879
|
+
{ k: "WHISPER_MODEL", label: "Whisper model", desc: "Transcription model size, e.g. base / small / medium.", ph: "base" },
|
|
880
|
+
]},
|
|
881
|
+
{ title: "Memory", fields: [
|
|
882
|
+
{ k: "MEMORY_RECALL_MAX_CHARS", label: "Memory recall limit", desc: "Max characters of memory injected into context per turn.", ph: "e.g. 6000" },
|
|
883
|
+
]},
|
|
884
|
+
{ title: "Usage alerts (advanced)", fields: [
|
|
885
|
+
{ k: "USAGE_ALERT_CONTEXT_TOKENS", label: "Context-token threshold", desc: "Warn when a turn's context exceeds this many tokens.", ph: "e.g. 150000" },
|
|
886
|
+
{ k: "USAGE_ALERT_RATE_MULTIPLIER", label: "Rate multiplier", desc: "Warn when usage exceeds the recent baseline by this factor.", ph: "e.g. 2" },
|
|
887
|
+
{ k: "USAGE_ALERT_BASELINE_TURNS", label: "Baseline window (turns)", desc: "How many recent turns form the usage baseline.", ph: "e.g. 20" },
|
|
888
|
+
{ k: "USAGE_ALERT_MIN_BASELINE_TURNS", label: "Min baseline turns", desc: "Minimum turns required before alerts activate.", ph: "e.g. 5" },
|
|
889
|
+
{ k: "USAGE_ALERT_COOLDOWN_MS", label: "Alert cooldown (ms)", desc: "Minimum gap between repeated usage alerts.", ph: "e.g. 300000" },
|
|
890
|
+
]},
|
|
805
891
|
];
|
|
806
|
-
$("#config-fields").innerHTML =
|
|
807
|
-
<div
|
|
808
|
-
|
|
892
|
+
$("#config-fields").innerHTML = groups.map(g => \`
|
|
893
|
+
<div style="margin-bottom:20px;">
|
|
894
|
+
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#9a9aff;border-bottom:1px solid #2a2a2a;padding-bottom:6px;margin-bottom:12px;">\${g.title}</div>
|
|
895
|
+
\${g.fields.map(f => \`
|
|
896
|
+
<div class="form-group">
|
|
897
|
+
<label>\${f.label}</label>
|
|
898
|
+
<div style="color:#777;font-size:11px;margin:2px 0 6px;">\${f.desc}</div>
|
|
899
|
+
<input id="cfg-\${f.k}" value="\${config[f.k] || ""}" placeholder="\${f.ph || ""}">
|
|
900
|
+
</div>\`).join("")}
|
|
901
|
+
</div>\`).join("");
|
|
809
902
|
}
|
|
810
903
|
}
|
|
811
904
|
|
|
@@ -878,6 +971,88 @@ async function removeAuth(chatId) { await api("/api/auth/remove", { method: "POS
|
|
|
878
971
|
async function approveAuth(chatId) { await api("/api/auth/approve", { method: "POST", body: { chatId } }); loadTab(); }
|
|
879
972
|
async function denyAuth(chatId) { await api("/api/auth/deny", { method: "POST", body: { chatId } }); loadTab(); }
|
|
880
973
|
|
|
974
|
+
async function loadCredStatus() {
|
|
975
|
+
const s = await api("/api/auth/status");
|
|
976
|
+
if (!s) return;
|
|
977
|
+
const yn = (b) => b ? "<span style='color:#22c55e'>yes</span>" : "<span style='color:#dc2626'>no</span>";
|
|
978
|
+
$("#cred-status").innerHTML = \`<h2>Credentials</h2>
|
|
979
|
+
<div class="list-item"><span>Claude OAuth token saved</span><span>\${yn(s.claude.configured)}</span></div>
|
|
980
|
+
<div class="list-item"><span>Claude logged in</span><span>\${yn(s.claude.loggedIn)}</span></div>
|
|
981
|
+
<div class="list-item"><span>Codex CLI present</span><span>\${yn(s.codex.cliFound)}</span></div>
|
|
982
|
+
<div class="list-item"><span>Codex logged in</span><span>\${yn(s.codex.loggedIn)}</span></div>\`;
|
|
983
|
+
}
|
|
984
|
+
async function startClaudeAuth() {
|
|
985
|
+
$("#claude-flow").innerHTML = "<p class='subtitle'>Starting…</p>";
|
|
986
|
+
await api("/api/auth/claude/start", { method: "POST" });
|
|
987
|
+
pollClaudeAuth();
|
|
988
|
+
}
|
|
989
|
+
async function pollClaudeAuth() {
|
|
990
|
+
const st = await api("/api/auth/claude/state");
|
|
991
|
+
if (!st) return;
|
|
992
|
+
const box = $("#claude-flow");
|
|
993
|
+
if (st.done) {
|
|
994
|
+
box.innerHTML = st.ok ? "<div class='msg ok'>Claude authenticated.</div>"
|
|
995
|
+
: (st.error === "tty" ? "<div class='msg err'>The Claude CLI needs a real terminal here. Paste an OAuth token below instead.</div>"
|
|
996
|
+
: "<div class='msg err'>Login didn't complete. Paste a token below instead.</div>");
|
|
997
|
+
loadCredStatus();
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (st.url) {
|
|
1001
|
+
box.innerHTML = \`<p style="font-size:13px;">Open this URL, sign in, then paste the code Claude gives you:</p>
|
|
1002
|
+
<p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
|
|
1003
|
+
<input id="claude-code" placeholder="Paste code here">
|
|
1004
|
+
<button onclick="submitClaudeCodeWeb()" style="margin-top:8px;">Submit code</button>\`;
|
|
1005
|
+
} else {
|
|
1006
|
+
box.innerHTML = "<p class='subtitle'>Starting…</p>";
|
|
1007
|
+
}
|
|
1008
|
+
setTimeout(pollClaudeAuth, 2000);
|
|
1009
|
+
}
|
|
1010
|
+
async function submitClaudeCodeWeb() {
|
|
1011
|
+
const code = $("#claude-code").value;
|
|
1012
|
+
await api("/api/auth/claude/code", { method: "POST", body: { code } });
|
|
1013
|
+
$("#claude-flow").innerHTML = "<p class='subtitle'>Submitting…</p>";
|
|
1014
|
+
setTimeout(pollClaudeAuth, 1500);
|
|
1015
|
+
}
|
|
1016
|
+
async function saveClaudeTokenWeb() {
|
|
1017
|
+
const token = $("#claude-token").value;
|
|
1018
|
+
const r = await api("/api/auth/claude/token", { method: "POST", body: { token } });
|
|
1019
|
+
$("#claude-token-msg").textContent = r && r.ok ? "Saved — restart to apply." : ((r && r.error) || "Failed");
|
|
1020
|
+
if (r && r.ok) { $("#claude-token").value = ""; loadCredStatus(); }
|
|
1021
|
+
}
|
|
1022
|
+
async function startCodexAuth() {
|
|
1023
|
+
$("#codex-flow").innerHTML = "<p class='subtitle'>Starting…</p>";
|
|
1024
|
+
const r = await api("/api/auth/codex/start", { method: "POST" });
|
|
1025
|
+
if (r && r.error) { $("#codex-flow").innerHTML = "<div class='msg err'>" + r.error + "</div>"; return; }
|
|
1026
|
+
pollCodexAuth();
|
|
1027
|
+
}
|
|
1028
|
+
async function pollCodexAuth() {
|
|
1029
|
+
const st = await api("/api/auth/codex/state");
|
|
1030
|
+
if (!st) return;
|
|
1031
|
+
const box = $("#codex-flow");
|
|
1032
|
+
if (st.done) {
|
|
1033
|
+
box.innerHTML = st.ok ? "<div class='msg ok'>Codex authenticated.</div>"
|
|
1034
|
+
: (st.error === "tty" ? "<div class='msg err'>The Codex CLI needs a real terminal here. Paste an API key below instead.</div>"
|
|
1035
|
+
: "<div class='msg err'>Login didn't complete. Paste an API key below instead.</div>");
|
|
1036
|
+
loadCredStatus();
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (st.url) {
|
|
1040
|
+
box.innerHTML = \`<p style="font-size:13px;">Open this URL and enter the code:</p>
|
|
1041
|
+
<p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
|
|
1042
|
+
\${st.code ? "<p>Device code: <b>" + st.code + "</b></p>" : ""}
|
|
1043
|
+
<p class='subtitle'>Waiting for you to finish in the browser…</p>\`;
|
|
1044
|
+
} else {
|
|
1045
|
+
box.innerHTML = "<p class='subtitle'>Starting…</p>";
|
|
1046
|
+
}
|
|
1047
|
+
setTimeout(pollCodexAuth, 2000);
|
|
1048
|
+
}
|
|
1049
|
+
async function saveCodexKeyWeb() {
|
|
1050
|
+
const key = $("#codex-key").value;
|
|
1051
|
+
const r = await api("/api/auth/codex/key", { method: "POST", body: { key } });
|
|
1052
|
+
$("#codex-key-msg").textContent = r && r.ok ? "Saved." : ((r && r.error) || "Failed");
|
|
1053
|
+
if (r && r.ok) { $("#codex-key").value = ""; loadCredStatus(); }
|
|
1054
|
+
}
|
|
1055
|
+
|
|
881
1056
|
async function saveSoul() {
|
|
882
1057
|
await api("/api/soul", { method: "POST", body: { content: $("#soul-content").value } });
|
|
883
1058
|
$("#soul-msg").textContent = "Saved";
|
|
@@ -903,7 +1078,7 @@ async function saveConfig() {
|
|
|
903
1078
|
function renderSetup() {
|
|
904
1079
|
$("#app").innerHTML = \`
|
|
905
1080
|
<div class="container">
|
|
906
|
-
<h1
|
|
1081
|
+
<h1>${botName} Setup</h1>
|
|
907
1082
|
<p class="subtitle">Configure your AI coding assistant</p>
|
|
908
1083
|
<div id="setup-msg"></div>
|
|
909
1084
|
<div class="card">
|