@inetafrica/open-claudia 2.14.8 → 2.15.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/CHANGELOG.md +9 -0
- package/bot.js +39 -4
- package/core/config.js +13 -1
- package/core/fsutil.js +38 -0
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/loopback.js +3 -2
- package/core/runner.js +14 -5
- package/core/state.js +19 -5
- package/core/web-sessions.js +78 -0
- package/package.json +6 -3
- package/test-delivery-contract.js +88 -0
- package/test-run-lock.js +63 -0
- package/test-web-sessions.js +74 -0
- package/web.js +29 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.15.0
|
|
4
|
+
- **Production-hardening pass (P0). Two root causes, closed at the source.** A deep-dive audit traced the reliability and safety gaps to two patterns: (1) **overloaded return contracts** where a falsy value meant both "no id" and "failed", so callers guessed — the exact shape behind v2.14.8's Kazee double-send; and (2) **unsafe shared state under concurrency**, amplified because the owner's Telegram + Kazee turns now share ONE in-memory state object *and* load-modify-write the same JSON files under unified identity. This release fixes both, plus the credential-exposure and crash-durability gaps found alongside them. No new deps, no new required env, no schema change.
|
|
5
|
+
- **One explicit delivery contract, so no caller guesses.** Adapters historically overloaded `send()`'s return: a message id on success, a bare `true` for an idless success (server fast-ack), or a falsy value on failure. `core/io.send()` now collapses every adapter's raw return through `normalizeSendResult` into `{ ok, messageId, editable }` — `messageId` is always a string or null, so `typeof`/edit-target checks are uniform across transports. The runner's final-delivery guard is now `if (!sent.ok)` (an idless success no longer reads as failure → no phantom re-send), and the streaming-preview path keeps the real string id when editable or a truthy sentinel otherwise, so `canEditStatus()` still declines to edit an unaddressable bubble. The loopback approval-prompt send routes through the same normalizer. New `test-delivery-contract.js`.
|
|
6
|
+
- **Crash-safe atomic writes for every durable JSON store.** `state.json`, `sessions.json`, and `identities.json` were each written with a bare `fs.writeFileSync(JSON.stringify(...))` — a crash or a full disk mid-write left a truncated file that fails to parse, and the loaders swallowed the parse error and returned `{}`, i.e. **silent total data loss** (every session pointer, per-user setting, and identity link gone). New `core/fsutil.js` provides `atomicWriteFileSync` (write to a temp file, `fsync`, then `rename` — atomic on POSIX — keeping a `.bak` sibling) and `readJsonWithFallback` (try the file, then its `.bak`, then the default). Every loader/writer in `core/state.js` and `core/identity.js` now uses them, and a failed write logs instead of vanishing. An interrupted flush now falls back to the last good file rather than wiping state.
|
|
7
|
+
- **A synchronous single-flight run-lock closes the double-spawn race.** The turn guard was `if (state.runningProcess)`, but `runningProcess` isn't set until *after* the pre-spawn awaits (recall, auto-compaction, auth preflight). Two turns for one canonical user — the owner's Telegram and Kazee messages sharing one state object — could both pass the check during that window and both spawn, racing the shared state and JSON files. New `state.acquireRunLock()` arms `state.preparingRun` **synchronously** (no `await` between test and set), so the second turn queues cleanly; the lock is released if a run bails before spawning (auth preflight) so a never-spawned turn can't wedge the next one. New `test-run-lock.js` proves the single-flight property on a shared state object.
|
|
8
|
+
- **Dashboard credential exposure closed on three fronts.** (1) `GET /api/config` returned the **raw `.env`** — every bot token, model API key, and the web control token in plaintext — to anyone holding a dashboard session; it now passes through `maskEnv`, which masks any secret-looking key to a 4-char tail (the config UI only ever edits the non-secret `SAFE_KEYS`, and masked values can't round-trip back in). (2) The session cookie was a **static `sha256(password)`** — identical for every login, unexpiring, and impossible to revoke short of a password change; replaced by `core/web-sessions.js`, which mints a random 256-bit token per login, tracks it server-side with a 24h expiry, validates the cookie against that store, and supports individual `revoke` + `revokeAll` (a password change now drops every outstanding session, then re-issues one for the caller). Sessions persist via the atomic writer, so a login survives a restart/upgrade. (3) The bot's own control-plane secrets (`TELEGRAM_BOT_TOKEN`, `KAZEE_BOT_TOKEN`, `BOT_CONTROL_TOKEN`) are now **stripped from the ambient subprocess env** (`botSubprocessEnv`) — the model CLI is authed by its own token/key and `open-claudia` CLIs re-read `.env` from disk, so nothing legitimately needs them, and a prompt-injected agent dumping its environment can no longer read them. New `test-web-sessions.js` (token lifecycle, targeted + mass revoke, expiry sweep, prototype-pollution-key rejection, disk persistence).
|
|
9
|
+
- **Crashes and shutdowns no longer lose state or die silently.** In-memory per-user state (settings, session pointers, usage, compaction timestamps) was only ever flushed opportunistically — a `SIGTERM` (every deploy/restart) or an uncaught exception dropped whatever hadn't been saved. A new best-effort synchronous `persist()` (→ `saveState()`, made crash-safe by the atomic writer above) now runs in `gracefulShutdown` and in the `uncaughtException` handler before exit. And `notifyError` — the last-ditch crash notifier — now **redacts secrets** before sending (`core/redact`), hardens the raw Telegram POST (`req.on("error")` guard so the notifier can't itself throw; correct `Content-Length` via `Buffer.byteLength`), and adds a second path that relays to the owner's **primary channel** via a live adapter, so a Kazee-only owner is reached too (skipped when the raw Telegram ping already covers a Telegram-primary owner — no double-notify).
|
|
10
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. All changes are correctness/safety hardening — inert-by-default and back-compatible, with one **visible behaviour change on upgrade**: because the dashboard session model changed, any existing dashboard cookie is invalidated once, so a logged-in browser is asked to log in again after this upgrade (expected, one-time). Three new hermetic tests (`test-run-lock.js`, `test-web-sessions.js`, `test-delivery-contract.js`) wired into `npm test`; full suite green.
|
|
11
|
+
|
|
3
12
|
## v2.14.8
|
|
4
13
|
- **Kazee replies no longer post twice.** On a successful send, the Kazee adapter's `send()` returned the created message's id so the runner can edit its streaming preview in place — but it read only `res.message._id`, then fell back to the ActionHero envelope's numeric `messageId`, then `res._id`. chat-central serves the new message's id under `message.id` on some routes (the adapter's own reply-to fetch already handles `m._id || m.id`), so when only `.id` was present `send()` returned `null`. The final-delivery path in `core/runner.js` reads that return as "did the send fail?": `const sent = await send(firstChunk, { replyTo }); if (!sent) await send(firstChunk);`. A successful-but-idless send therefore looked like a failure and the reply was posted a second time — once as a reply-quote, once as a plain bubble (exactly the pair seen in-chat). `send()` now returns `message._id || message.id` (or the top-level equivalents) and **never** the numeric envelope `messageId` — that counter is a per-request id, not a chat message id, and using it also made later `PUT /message/<counter>` edits 500.
|
|
5
14
|
- **A streaming preview minted on one channel is never edited on another.** Under unified identity the owner's Telegram and Kazee turns share ONE per-canonical state object, and `statusMessageId` (the id of the live "typing…/preview" bubble) sat on it untagged. A Telegram message id (e.g. `24991`) could then be edited under the Kazee adapter → `PUT /message/24991` → *"Cast to ObjectId failed"* 500, a frozen preview, and a duplicate final send. `statusMessageId` now carries the channel it was minted on (`statusMessageChannel`, `core/state.js`); every edit-in-place site in `core/runner.js` (streaming tick + final delivery + the external-hold notice) only edits when the channel matches, otherwise it discards the stale id and sends fresh on the correct channel. Telegram-only deployments are unaffected (single channel → always matches).
|
package/bot.js
CHANGED
|
@@ -44,6 +44,18 @@ if (adapters.length === 0) {
|
|
|
44
44
|
const { userStates } = require("./core/state");
|
|
45
45
|
const { killProcessTree } = require("./core/process-tree");
|
|
46
46
|
|
|
47
|
+
// Flush durable in-memory state (per-user settings, session pointers, usage,
|
|
48
|
+
// compaction timestamps) to disk. Synchronous + best-effort so it is safe to
|
|
49
|
+
// call from a shutdown signal or a crash handler, where the event loop may not
|
|
50
|
+
// survive an async write. Atomic writes (core/fsutil) make an interrupted flush
|
|
51
|
+
// safe: a reader falls back to the last good file or its .bak sibling. Only
|
|
52
|
+
// per-user state is held in memory between turns — sessions and identities are
|
|
53
|
+
// written on every mutation, so there is nothing else to flush here.
|
|
54
|
+
function persist() {
|
|
55
|
+
try { require("./core/state").saveState(); }
|
|
56
|
+
catch (e) { console.error("persist: saveState failed:", e?.message); }
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
async function gracefulShutdown(signal) {
|
|
48
60
|
console.log(`Received ${signal}, shutting down gracefully...`);
|
|
49
61
|
for (const state of userStates.values()) {
|
|
@@ -68,17 +80,28 @@ async function gracefulShutdown(signal) {
|
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
} catch (e) {}
|
|
83
|
+
persist();
|
|
71
84
|
process.exit(0);
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
75
88
|
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
76
89
|
|
|
77
|
-
// Last-ditch crash notification
|
|
78
|
-
//
|
|
90
|
+
// Last-ditch crash notification: surface failures to the owner instead of
|
|
91
|
+
// letting the process die silently. Secrets are redacted first. Two additive
|
|
92
|
+
// paths so a non-Telegram owner is still reached:
|
|
93
|
+
// 1) a raw Telegram POST — no adapter/people deps, safe even when the event
|
|
94
|
+
// loop is wedged; owns the (common) Telegram-primary owner, and
|
|
95
|
+
// 2) a best-effort relay to the owner's primary channel via a live adapter,
|
|
96
|
+
// which covers a Kazee-only owner the raw path can't. Skipped when the raw
|
|
97
|
+
// Telegram ping already covers a Telegram-primary owner (no double-notify).
|
|
79
98
|
function notifyError(label, err) {
|
|
80
|
-
|
|
99
|
+
let msg = `${label}: ${err?.message || err}`;
|
|
100
|
+
try { msg = require("./core/redact").redactSensitive(msg); } catch (e) {}
|
|
101
|
+
msg = msg.slice(0, 1000);
|
|
81
102
|
console.error(msg, err?.stack || "");
|
|
103
|
+
|
|
104
|
+
let rawTelegram = false;
|
|
82
105
|
try {
|
|
83
106
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
84
107
|
const chatId = process.env.TELEGRAM_CHAT_ID?.split(",")[0];
|
|
@@ -86,16 +109,28 @@ function notifyError(label, err) {
|
|
|
86
109
|
const data = JSON.stringify({ chat_id: chatId, text: msg });
|
|
87
110
|
const req = require("https").request({
|
|
88
111
|
hostname: "api.telegram.org", path: `/bot${token}/sendMessage`,
|
|
89
|
-
method: "POST", headers: { "Content-Type": "application/json", "Content-Length": data
|
|
112
|
+
method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
|
|
90
113
|
});
|
|
114
|
+
req.on("error", () => {}); // the notifier must never itself throw
|
|
91
115
|
req.write(data);
|
|
92
116
|
req.end();
|
|
117
|
+
rawTelegram = true;
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const owner = require("./core/people").owners()[0];
|
|
123
|
+
const primary = owner?.primaryChannel?.adapter;
|
|
124
|
+
const alreadyCovered = rawTelegram && (!primary || primary === "telegram");
|
|
125
|
+
if (owner && !alreadyCovered) {
|
|
126
|
+
require("./core/relay").send({ text: msg, target: { personId: owner.id }, kind: "crash-notify" }).catch(() => {});
|
|
93
127
|
}
|
|
94
128
|
} catch (e) {}
|
|
95
129
|
}
|
|
96
130
|
|
|
97
131
|
process.on("uncaughtException", (err) => {
|
|
98
132
|
notifyError("Uncaught exception", err);
|
|
133
|
+
try { persist(); } catch (e) {}
|
|
99
134
|
setTimeout(() => process.exit(1), 2000);
|
|
100
135
|
});
|
|
101
136
|
process.on("unhandledRejection", (reason) => notifyError("Unhandled rejection", reason));
|
package/core/config.js
CHANGED
|
@@ -229,13 +229,25 @@ function loadChannels() {
|
|
|
229
229
|
return channels;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// The bot's own control-plane secrets. No subprocess the bot spawns — the
|
|
233
|
+
// Claude/Codex/Cursor CLI, sub-agents, the recall walker, tools, auth flows —
|
|
234
|
+
// ever needs these: the model CLI is authed via CLAUDE_CODE_OAUTH_TOKEN (added
|
|
235
|
+
// on top by claudeSubprocessEnv) or its own API key, and `open-claudia` CLI
|
|
236
|
+
// invocations re-read the .env file from disk. Stripping them from the ambient
|
|
237
|
+
// subprocess env closes the easiest exfil path — a prompt-injected agent (or any
|
|
238
|
+
// child) dumping its own environment can no longer read the bot's Telegram/Kazee
|
|
239
|
+
// tokens or the web control token.
|
|
240
|
+
const CONFINED_SECRET_KEYS = ["TELEGRAM_BOT_TOKEN", "KAZEE_BOT_TOKEN", "BOT_CONTROL_TOKEN"];
|
|
241
|
+
|
|
232
242
|
function botSubprocessEnv() {
|
|
233
243
|
// Credential confinement (5b): the operational keyring is deliberately NOT
|
|
234
244
|
// merged here. Agent shells, sub-agents, and every other bot subprocess run
|
|
235
245
|
// cred-free; keyring values enter ONLY tool-run subprocesses (tools.runEnv —
|
|
236
246
|
// the keys a tool declares via --requires) and the logged escape hatch
|
|
237
247
|
// (`keyring exec --reason`). See core/tool-guard.js for the deny-gate half.
|
|
238
|
-
|
|
248
|
+
const env = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
|
|
249
|
+
for (const k of CONFINED_SECRET_KEYS) delete env[k];
|
|
250
|
+
return env;
|
|
239
251
|
}
|
|
240
252
|
|
|
241
253
|
module.exports = {
|
package/core/fsutil.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Crash-safe JSON persistence. A bare writeFileSync can be interrupted
|
|
2
|
+
// mid-write (crash, OOM kill, power loss) and leave a truncated, unparseable
|
|
3
|
+
// file — for irreplaceable state (identities, sessions, per-user buckets) that
|
|
4
|
+
// is data loss. Instead we write to a sibling temp file, fsync it to durable
|
|
5
|
+
// storage, optionally snapshot the last-good file to `<file>.bak`, then rename
|
|
6
|
+
// the temp over the target. A POSIX rename is atomic, so a reader always sees
|
|
7
|
+
// either the whole old file or the whole new one — never a half-written one.
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
function atomicWriteFileSync(file, data, { backup = false } = {}) {
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
14
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
15
|
+
const fd = fs.openSync(tmp, "w");
|
|
16
|
+
try {
|
|
17
|
+
fs.writeSync(fd, data);
|
|
18
|
+
fs.fsyncSync(fd);
|
|
19
|
+
} finally {
|
|
20
|
+
fs.closeSync(fd);
|
|
21
|
+
}
|
|
22
|
+
// Snapshot the previous good version before we overwrite, so a corrupt or
|
|
23
|
+
// wrong write is still recoverable via readJsonWithFallback.
|
|
24
|
+
if (backup) {
|
|
25
|
+
try { if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`); } catch (e) {}
|
|
26
|
+
}
|
|
27
|
+
fs.renameSync(tmp, file);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Parse JSON from `file`, transparently falling back to `<file>.bak` if the
|
|
31
|
+
// primary is missing or corrupt, and to `fallback` if neither is usable.
|
|
32
|
+
function readJsonWithFallback(file, fallback) {
|
|
33
|
+
try { return JSON.parse(fs.readFileSync(file, "utf-8")); } catch (e) {}
|
|
34
|
+
try { return JSON.parse(fs.readFileSync(`${file}.bak`, "utf-8")); } catch (e) {}
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { atomicWriteFileSync, readJsonWithFallback };
|
package/core/identity.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// transport. Default canonical is `<transport>:<channelId>`; the user
|
|
4
4
|
// can override with /link <email-or-id> to merge state across channels.
|
|
5
5
|
|
|
6
|
-
const fs = require("fs");
|
|
7
6
|
const { IDENTITIES_FILE } = require("./config");
|
|
7
|
+
const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
|
|
8
8
|
|
|
9
9
|
function normalizeCanonicalUserId(value) {
|
|
10
10
|
return String(value || "").trim().toLowerCase();
|
|
@@ -19,21 +19,18 @@ function defaultCanonicalForChannel(transport, channelId) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function loadIdentities() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
};
|
|
28
|
-
} catch (e) {
|
|
29
|
-
return { channels: {}, preferred: {} };
|
|
30
|
-
}
|
|
22
|
+
const raw = readJsonWithFallback(IDENTITIES_FILE, null);
|
|
23
|
+
return {
|
|
24
|
+
channels: raw && typeof raw.channels === "object" ? raw.channels : {},
|
|
25
|
+
preferred: raw && typeof raw.preferred === "object" ? raw.preferred : {},
|
|
26
|
+
};
|
|
31
27
|
}
|
|
32
28
|
|
|
33
29
|
const identities = loadIdentities();
|
|
34
30
|
|
|
35
31
|
function saveIdentities() {
|
|
36
|
-
try {
|
|
32
|
+
try { atomicWriteFileSync(IDENTITIES_FILE, JSON.stringify(identities, null, 2), { backup: true }); }
|
|
33
|
+
catch (e) { console.error("saveIdentities: write failed:", e.message); }
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
// A channel is a "configured owner channel" when its (transport, channelId)
|
package/core/io.js
CHANGED
|
@@ -4,14 +4,35 @@
|
|
|
4
4
|
|
|
5
5
|
const { currentAdapter, currentChannelId, currentUserId } = require("./context");
|
|
6
6
|
|
|
7
|
+
// Adapters historically overloaded their send() return value: a message id
|
|
8
|
+
// (Telegram numeric, Kazee ObjectId string) on success, a bare `true` when
|
|
9
|
+
// delivery succeeded but no editable id came back (server fast-ack), or a
|
|
10
|
+
// falsy value (null/false/undefined) on failure. Callers then guessed —
|
|
11
|
+
// `if (!sent)` read an idless success as a failure (the Kazee double-send),
|
|
12
|
+
// and a `typeof === "string"` edit-guard silently rejected Telegram's numeric
|
|
13
|
+
// id. Collapse every adapter's raw return into ONE explicit shape so no caller
|
|
14
|
+
// has to guess: { ok, messageId, editable }. messageId is always a string or
|
|
15
|
+
// null, so downstream typeof/edit-target checks are uniform across transports.
|
|
16
|
+
function normalizeSendResult(raw) {
|
|
17
|
+
if (raw === null || raw === undefined || raw === false) {
|
|
18
|
+
return { ok: false, messageId: null, editable: false };
|
|
19
|
+
}
|
|
20
|
+
if (raw === true) {
|
|
21
|
+
return { ok: true, messageId: null, editable: false };
|
|
22
|
+
}
|
|
23
|
+
const id = String(raw);
|
|
24
|
+
if (!id) return { ok: true, messageId: null, editable: false };
|
|
25
|
+
return { ok: true, messageId: id, editable: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
7
28
|
async function send(text, opts = {}) {
|
|
8
29
|
const adapter = currentAdapter();
|
|
9
30
|
const channelId = opts.channelId || currentChannelId();
|
|
10
31
|
if (!adapter || !channelId) {
|
|
11
32
|
console.error("send() called outside chat context");
|
|
12
|
-
return null;
|
|
33
|
+
return { ok: false, messageId: null, editable: false };
|
|
13
34
|
}
|
|
14
|
-
return adapter.send(channelId, text, opts);
|
|
35
|
+
return normalizeSendResult(await adapter.send(channelId, text, opts));
|
|
15
36
|
}
|
|
16
37
|
|
|
17
38
|
async function editMessage(messageId, text, opts = {}) {
|
|
@@ -75,4 +96,4 @@ function splitMessage(text, maxLen = 4000) {
|
|
|
75
96
|
return chunks;
|
|
76
97
|
}
|
|
77
98
|
|
|
78
|
-
module.exports = { send, editMessage, deleteMessage, sendVoice, sendVoiceEnd, sendFile, typing, splitMessage };
|
|
99
|
+
module.exports = { send, editMessage, deleteMessage, sendVoice, sendVoiceEnd, sendFile, typing, splitMessage, normalizeSendResult };
|
package/core/loopback.js
CHANGED
|
@@ -512,8 +512,9 @@ async function handleJson(req, res, url, kind) {
|
|
|
512
512
|
// <pre> needs Telegram's HTML parse mode to render; other adapters ignore it.
|
|
513
513
|
const sendOpts = { keyboard };
|
|
514
514
|
if (adapter.type === "telegram") sendOpts.parseMode = "HTML";
|
|
515
|
-
const
|
|
516
|
-
|
|
515
|
+
const { normalizeSendResult } = require("./io");
|
|
516
|
+
const sent = normalizeSendResult(await adapter.send(channelId, text, sendOpts));
|
|
517
|
+
if (!sent.ok) { approvals.decide(rec.id, "denied", "send-failed"); return reply(res, 500, { error: "could not deliver approval prompt" }); }
|
|
517
518
|
audit.log("tool.approval.requested", { id: rec.id, tool: rec.tool, verb: rec.verb, channelId });
|
|
518
519
|
return reply(res, 200, { ok: true, id: rec.id });
|
|
519
520
|
} catch (e) { return reply(res, 400, { error: e.message }); }
|
package/core/runner.js
CHANGED
|
@@ -11,7 +11,7 @@ const {
|
|
|
11
11
|
AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT, botSubprocessEnv,
|
|
12
12
|
CONFIG_DIR, WORKSPACE, config,
|
|
13
13
|
} = require("./config");
|
|
14
|
-
const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSessionUsage } = require("./state");
|
|
14
|
+
const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSessionUsage, acquireRunLock } = require("./state");
|
|
15
15
|
const { chatContext, currentChannelId, currentAdapter, currentUserId } = require("./context");
|
|
16
16
|
const { splitLeadingOriginGroup } = require("./queue-drain");
|
|
17
17
|
const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
|
|
@@ -803,7 +803,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
803
803
|
try { externalSpeaker = require("./relationship").isCurrentSpeakerGuarded(); } catch (e) {}
|
|
804
804
|
cwd = resolveRunCwd(cwd);
|
|
805
805
|
|
|
806
|
-
if (state
|
|
806
|
+
if (!acquireRunLock(state)) {
|
|
807
807
|
// Capture the origin channel so the drained reply routes back to where the
|
|
808
808
|
// message came from. Under unified identity the owner's channels share one
|
|
809
809
|
// run-lock and queue, so a Kazee message queued during a Telegram run must
|
|
@@ -815,6 +815,9 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
815
815
|
|
|
816
816
|
const authPreflight = preflightClaudeAuthMessage();
|
|
817
817
|
if (authPreflight) {
|
|
818
|
+
// Lock was armed synchronously above; release it before bailing so the
|
|
819
|
+
// next turn isn't wedged behind a run that never spawned.
|
|
820
|
+
state.preparingRun = false;
|
|
818
821
|
await send(authPreflight, { replyTo: replyToMsgId });
|
|
819
822
|
return;
|
|
820
823
|
}
|
|
@@ -864,7 +867,8 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
864
867
|
};
|
|
865
868
|
// Pre-spawn window (recall/compaction/auth): /stop has no process to kill, so
|
|
866
869
|
// it sets state.cancelRequested and we bail at the checkpoint before spawning.
|
|
867
|
-
|
|
870
|
+
// preparingRun was armed synchronously by acquireRunLock() at the top of this
|
|
871
|
+
// function so the lock already spans the auth/compact awaits above — no re-arm.
|
|
868
872
|
state.cancelRequested = false;
|
|
869
873
|
startTyping();
|
|
870
874
|
state.statusMessageId = null;
|
|
@@ -1252,7 +1256,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1252
1256
|
state.statusMessageChannel = null;
|
|
1253
1257
|
}
|
|
1254
1258
|
if (!state.statusMessageId && assistantText) {
|
|
1255
|
-
|
|
1259
|
+
const previewResult = await send(display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ replyTo: replyToMsgId }));
|
|
1260
|
+
// Keep the real string id when the preview is editable (Telegram +
|
|
1261
|
+
// Kazee both normalize to a string now); otherwise a truthy sentinel
|
|
1262
|
+
// so we don't re-post a fresh preview each tick, while canEditStatus()
|
|
1263
|
+
// (typeof string) still declines to edit an unaddressable message.
|
|
1264
|
+
state.statusMessageId = previewResult.editable ? previewResult.messageId : (previewResult.ok ? true : null);
|
|
1256
1265
|
state.statusMessageChannel = channelId;
|
|
1257
1266
|
} else if (canEditStatus()) {
|
|
1258
1267
|
await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ streaming: true }));
|
|
@@ -1528,7 +1537,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1528
1537
|
await editMessage(state.statusMessageId, firstChunk, telegramHtmlOpts({ streaming: true }));
|
|
1529
1538
|
} else {
|
|
1530
1539
|
const sent = await send(firstChunk, telegramHtmlOpts({ replyTo: replyToMsgId }));
|
|
1531
|
-
if (!sent) await send(firstChunk);
|
|
1540
|
+
if (!sent.ok) await send(firstChunk);
|
|
1532
1541
|
for (let i = 1; i < chunks.length; i++) {
|
|
1533
1542
|
await send(chunks[i], telegramHtmlOpts());
|
|
1534
1543
|
}
|
package/core/state.js
CHANGED
|
@@ -17,9 +17,10 @@ const {
|
|
|
17
17
|
identities,
|
|
18
18
|
} = require("./identity");
|
|
19
19
|
const { currentChannelId, currentCanonicalUserId } = require("./context");
|
|
20
|
+
const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
|
|
20
21
|
|
|
21
22
|
function loadStateFile() {
|
|
22
|
-
|
|
23
|
+
return readJsonWithFallback(STATE_FILE, {});
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function mergeSavedState(existing, next) {
|
|
@@ -120,6 +121,17 @@ function resetSettings(state = currentState()) {
|
|
|
120
121
|
state.settings = freshSettings();
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
// Synchronous single-flight run-lock. Returns true and arms the lock if no turn
|
|
125
|
+
// is running or preparing for this user; false if the caller must queue. There
|
|
126
|
+
// must be NO await between calling this and the work it guards, so two turns for
|
|
127
|
+
// one canonical user (owner's Telegram + Kazee sharing one state under unified
|
|
128
|
+
// identity) can never both acquire and then race the shared state + JSON files.
|
|
129
|
+
function acquireRunLock(state = currentState()) {
|
|
130
|
+
if (state.runningProcess || state.preparingRun) return false;
|
|
131
|
+
state.preparingRun = true;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
function saveState() {
|
|
124
136
|
const data = { users: { ...(savedState.users || {}) } };
|
|
125
137
|
for (const [id, s] of userStates) {
|
|
@@ -135,14 +147,14 @@ function saveState() {
|
|
|
135
147
|
};
|
|
136
148
|
}
|
|
137
149
|
savedState.users = data.users;
|
|
138
|
-
try {
|
|
150
|
+
try { atomicWriteFileSync(STATE_FILE, JSON.stringify(data), { backup: true }); }
|
|
151
|
+
catch (e) { console.error("saveState: write failed:", e.message); }
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
// ── Sessions ────────────────────────────────────────────────────────
|
|
142
155
|
|
|
143
156
|
function loadSessions() {
|
|
144
|
-
|
|
145
|
-
try { raw = JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf-8")); } catch (e) { return {}; }
|
|
157
|
+
const raw = readJsonWithFallback(SESSIONS_FILE, null);
|
|
146
158
|
if (!raw || typeof raw !== "object") return {};
|
|
147
159
|
const looksLegacy = Object.values(raw).some((v) => Array.isArray(v));
|
|
148
160
|
if (looksLegacy) return { [canonicalForTelegram(CHAT_ID || "")]: raw };
|
|
@@ -161,7 +173,8 @@ function loadSessions() {
|
|
|
161
173
|
}
|
|
162
174
|
|
|
163
175
|
function saveSessions(sessions) {
|
|
164
|
-
try {
|
|
176
|
+
try { atomicWriteFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2), { backup: true }); }
|
|
177
|
+
catch (e) { console.error("saveSessions: write failed:", e.message); }
|
|
165
178
|
}
|
|
166
179
|
|
|
167
180
|
function recordSession(userId, projectName, sessionId, title) {
|
|
@@ -308,6 +321,7 @@ module.exports = {
|
|
|
308
321
|
currentState,
|
|
309
322
|
resetSessionUsage,
|
|
310
323
|
resetSettings,
|
|
324
|
+
acquireRunLock,
|
|
311
325
|
saveState,
|
|
312
326
|
loadSessions,
|
|
313
327
|
saveSessions,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Revocable dashboard sessions. The web login used to set the cookie to a
|
|
2
|
+
// static sha256(password): identical for every session, valid until the password
|
|
3
|
+
// changed, and impossible to revoke — a leaked cookie or /otp link granted
|
|
4
|
+
// permanent access. Instead we mint a random 256-bit token per login, track it
|
|
5
|
+
// server-side with an expiry, and validate the cookie against that store. Tokens
|
|
6
|
+
// can be individually revoked, expire on their own, and are all dropped when the
|
|
7
|
+
// password changes. Persisted via the atomic writer (core/fsutil) so a login
|
|
8
|
+
// survives a bot restart/upgrade, and a torn write falls back to the last good
|
|
9
|
+
// file rather than logging everyone out.
|
|
10
|
+
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const CONFIG_DIR = require("../config-dir");
|
|
14
|
+
const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
|
|
15
|
+
|
|
16
|
+
const SESSIONS_FILE = path.join(CONFIG_DIR, "web-sessions.json");
|
|
17
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // 24h — matches the cookie Max-Age
|
|
18
|
+
|
|
19
|
+
function load() {
|
|
20
|
+
const raw = readJsonWithFallback(SESSIONS_FILE, {});
|
|
21
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function persist(sessions) {
|
|
25
|
+
try { atomicWriteFileSync(SESSIONS_FILE, JSON.stringify(sessions), { backup: true }); }
|
|
26
|
+
catch (e) { console.error("web-sessions: write failed:", e.message); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Drop expired (or malformed) entries in place. Returns true if anything went.
|
|
30
|
+
function sweep(sessions, now = Date.now()) {
|
|
31
|
+
let changed = false;
|
|
32
|
+
for (const token of Object.keys(sessions)) {
|
|
33
|
+
const meta = sessions[token];
|
|
34
|
+
if (!meta || typeof meta.expires !== "number" || meta.expires <= now) {
|
|
35
|
+
delete sessions[token];
|
|
36
|
+
changed = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return changed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Mint and persist a fresh session token, returning it for the Set-Cookie.
|
|
43
|
+
function issue() {
|
|
44
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
45
|
+
const sessions = load();
|
|
46
|
+
sweep(sessions);
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
sessions[token] = { created: now, expires: now + TTL_MS };
|
|
49
|
+
persist(sessions);
|
|
50
|
+
return token;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Is this cookie token a live, unexpired session? hasOwnProperty guards against
|
|
54
|
+
// an attacker presenting "__proto__"/"constructor" and hitting the prototype.
|
|
55
|
+
function valid(token) {
|
|
56
|
+
if (!token || typeof token !== "string") return false;
|
|
57
|
+
const sessions = load();
|
|
58
|
+
if (!Object.prototype.hasOwnProperty.call(sessions, token)) return false;
|
|
59
|
+
const meta = sessions[token];
|
|
60
|
+
if (!meta || typeof meta.expires !== "number" || meta.expires <= Date.now()) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function revoke(token) {
|
|
65
|
+
if (!token) return;
|
|
66
|
+
const sessions = load();
|
|
67
|
+
if (Object.prototype.hasOwnProperty.call(sessions, token)) {
|
|
68
|
+
delete sessions[token];
|
|
69
|
+
persist(sessions);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Kill every session — used when the password changes.
|
|
74
|
+
function revokeAll() {
|
|
75
|
+
persist({});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { issue, valid, revoke, revokeAll, sweep, TTL_MS, SESSIONS_FILE };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.0",
|
|
4
4
|
"description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-kazee-message-id.js"
|
|
12
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-web-sessions.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -61,7 +61,10 @@
|
|
|
61
61
|
"test-relationship.js",
|
|
62
62
|
"test-enforcer.js",
|
|
63
63
|
"test-identity-prune.js",
|
|
64
|
-
"test-kazee-message-id.js"
|
|
64
|
+
"test-kazee-message-id.js",
|
|
65
|
+
"test-delivery-contract.js",
|
|
66
|
+
"test-run-lock.js",
|
|
67
|
+
"test-web-sessions.js"
|
|
65
68
|
],
|
|
66
69
|
"keywords": [
|
|
67
70
|
"claude",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// P0-6: end-to-end delivery-contract regression test. Drives core/io.send()
|
|
2
|
+
// through fake adapters that reproduce every real adapter return shape
|
|
3
|
+
// (Telegram numeric id, Kazee ObjectId string, Kazee idless-success sentinel,
|
|
4
|
+
// hard failure) and pins the normalized { ok, messageId, editable } contract
|
|
5
|
+
// plus the two behaviours that contract exists to guarantee:
|
|
6
|
+
// 1. an idless success is NEVER re-sent (the v2.14.8 Kazee double-send), and
|
|
7
|
+
// 2. a real failure IS re-sent exactly once.
|
|
8
|
+
// No network, no bot — just the async chat scope + the io layer.
|
|
9
|
+
|
|
10
|
+
const assert = require("assert");
|
|
11
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
12
|
+
|
|
13
|
+
const io = require("./core/io");
|
|
14
|
+
const { runInChat } = require("./core/context");
|
|
15
|
+
|
|
16
|
+
// A fake adapter whose send() returns a programmed value and records each call.
|
|
17
|
+
function makeAdapter(type, returns) {
|
|
18
|
+
const calls = [];
|
|
19
|
+
const adapter = {
|
|
20
|
+
type, id: type,
|
|
21
|
+
send: async (channelId, text, opts) => {
|
|
22
|
+
calls.push({ channelId: String(channelId), text, opts: opts || {} });
|
|
23
|
+
return typeof returns === "function" ? returns(calls.length - 1) : returns;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
return { adapter, calls };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const inChat = (adapter, fn) => runInChat({ adapter, channelId: "chan-1", transport: adapter.type }, fn);
|
|
30
|
+
|
|
31
|
+
(async () => {
|
|
32
|
+
// 1. Telegram numeric id → editable, id normalized to a string.
|
|
33
|
+
{
|
|
34
|
+
const { adapter, calls } = makeAdapter("telegram", 4242);
|
|
35
|
+
const r = await inChat(adapter, () => io.send("hi"));
|
|
36
|
+
assert.deepStrictEqual(r, { ok: true, messageId: "4242", editable: true }, "telegram numeric id");
|
|
37
|
+
assert.strictEqual(calls.length, 1, "telegram sent once");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Kazee ObjectId string → editable.
|
|
41
|
+
{
|
|
42
|
+
const { adapter } = makeAdapter("kazee", "67aef6a338dd0d6db55c684c");
|
|
43
|
+
const r = await inChat(adapter, () => io.send("hi"));
|
|
44
|
+
assert.deepStrictEqual(r, { ok: true, messageId: "67aef6a338dd0d6db55c684c", editable: true }, "kazee objectid");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. Kazee idless success (bare true) → delivered but NOT editable.
|
|
48
|
+
{
|
|
49
|
+
const { adapter } = makeAdapter("kazee", true);
|
|
50
|
+
const r = await inChat(adapter, () => io.send("hi"));
|
|
51
|
+
assert.deepStrictEqual(r, { ok: true, messageId: null, editable: false }, "kazee idless success");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 4. Hard failures collapse to a single not-ok shape.
|
|
55
|
+
for (const bad of [null, undefined, false]) {
|
|
56
|
+
const { adapter } = makeAdapter("telegram", bad);
|
|
57
|
+
const r = await inChat(adapter, () => io.send("hi"));
|
|
58
|
+
assert.deepStrictEqual(r, { ok: false, messageId: null, editable: false }, `failure ${String(bad)}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 5. THE guard: mirror runner.js final delivery —
|
|
62
|
+
// const sent = await send(x); if (!sent.ok) await send(x);
|
|
63
|
+
const deliver = async () => {
|
|
64
|
+
const sent = await io.send("reply");
|
|
65
|
+
if (!sent.ok) await io.send("reply");
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
// idless success must resolve to exactly ONE post (no double-send).
|
|
70
|
+
const { adapter, calls } = makeAdapter("kazee", true);
|
|
71
|
+
await inChat(adapter, deliver);
|
|
72
|
+
assert.strictEqual(calls.length, 1, "idless success must NOT double-send (v2.14.8 regression)");
|
|
73
|
+
}
|
|
74
|
+
{
|
|
75
|
+
// a real failure must trigger exactly one resend (two posts total).
|
|
76
|
+
const { adapter, calls } = makeAdapter("telegram", null);
|
|
77
|
+
await inChat(adapter, deliver);
|
|
78
|
+
assert.strictEqual(calls.length, 2, "hard failure must resend exactly once");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 6. loopback approval-prompt path uses the exported normalizer on a raw
|
|
82
|
+
// adapter return and must get the identical contract.
|
|
83
|
+
assert.deepStrictEqual(io.normalizeSendResult(true), { ok: true, messageId: null, editable: false });
|
|
84
|
+
assert.deepStrictEqual(io.normalizeSendResult(null), { ok: false, messageId: null, editable: false });
|
|
85
|
+
assert.deepStrictEqual(io.normalizeSendResult(99), { ok: true, messageId: "99", editable: true });
|
|
86
|
+
|
|
87
|
+
console.log("delivery-contract OK");
|
|
88
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|
package/test-run-lock.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// P0-3: single-flight run-lock regression test. Under unified identity the
|
|
2
|
+
// owner's Telegram and Kazee turns resolve to ONE canonical user and therefore
|
|
3
|
+
// share ONE in-memory state object. If two of those turns could both pass the
|
|
4
|
+
// "is a run in progress?" check they would race the shared state and the JSON
|
|
5
|
+
// files behind it. acquireRunLock() closes that window by arming the lock
|
|
6
|
+
// SYNCHRONOUSLY (no await between the check and the arm), so the second turn is
|
|
7
|
+
// always refused and queued. This test pins that contract on the raw helper.
|
|
8
|
+
|
|
9
|
+
const assert = require("assert");
|
|
10
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
11
|
+
|
|
12
|
+
const { acquireRunLock } = require("./core/state");
|
|
13
|
+
|
|
14
|
+
// A run-lock must be synchronous — its whole point is that no await can
|
|
15
|
+
// interleave between the check and the arm. If this ever returned a Promise the
|
|
16
|
+
// guarantee would silently evaporate.
|
|
17
|
+
{
|
|
18
|
+
const r = acquireRunLock({ runningProcess: null, preparingRun: false });
|
|
19
|
+
assert.strictEqual(typeof r, "boolean", "acquireRunLock must be synchronous (return a boolean, not a Promise)");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Idle → acquires and arms preparingRun.
|
|
23
|
+
{
|
|
24
|
+
const s = { runningProcess: null, preparingRun: false };
|
|
25
|
+
assert.strictEqual(acquireRunLock(s), true, "idle state acquires");
|
|
26
|
+
assert.strictEqual(s.preparingRun, true, "acquiring arms preparingRun");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Already preparing (armed but not yet spawned) → refused, lock left intact.
|
|
30
|
+
{
|
|
31
|
+
const s = { runningProcess: null, preparingRun: true };
|
|
32
|
+
assert.strictEqual(acquireRunLock(s), false, "preparing state is refused");
|
|
33
|
+
assert.strictEqual(s.preparingRun, true, "refusal leaves the existing lock armed");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A process is actively running → refused.
|
|
37
|
+
{
|
|
38
|
+
const s = { runningProcess: { pid: 123 }, preparingRun: false };
|
|
39
|
+
assert.strictEqual(acquireRunLock(s), false, "running process is refused");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// THE guarantee: two turns sharing ONE state object (owner's Telegram + Kazee
|
|
43
|
+
// under unified identity). Exactly one may acquire; the other must be refused
|
|
44
|
+
// until the first fully releases.
|
|
45
|
+
{
|
|
46
|
+
const shared = { runningProcess: null, preparingRun: false };
|
|
47
|
+
|
|
48
|
+
const first = acquireRunLock(shared); // Telegram turn arrives
|
|
49
|
+
const second = acquireRunLock(shared); // Kazee turn arrives before the first releases
|
|
50
|
+
assert.strictEqual(first, true, "first turn wins the lock");
|
|
51
|
+
assert.strictEqual(second, false, "second concurrent turn is refused (no double-acquire)");
|
|
52
|
+
|
|
53
|
+
// First turn spawns its process, then finishes and clears both flags.
|
|
54
|
+
shared.preparingRun = false;
|
|
55
|
+
shared.runningProcess = { pid: 1 }; // spawned
|
|
56
|
+
assert.strictEqual(acquireRunLock(shared), false, "still refused while the process runs");
|
|
57
|
+
shared.runningProcess = null; // turn complete
|
|
58
|
+
|
|
59
|
+
// Now the queued second turn (drained from messageQueue) can proceed.
|
|
60
|
+
assert.strictEqual(acquireRunLock(shared), true, "lock is re-acquirable once the turn fully releases");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log("run-lock OK");
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// P0-4: revocable dashboard sessions. The old web login set the cookie to a
|
|
2
|
+
// static sha256(password) — identical for every session, unexpiring, and
|
|
3
|
+
// impossible to revoke. This pins the replacement contract: random per-login
|
|
4
|
+
// tokens, validated server-side, individually revocable, killable en masse on
|
|
5
|
+
// password change, expiring on their own, and surviving a restart (persisted).
|
|
6
|
+
// Prototype-pollution keys must never validate.
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const assert = require("assert");
|
|
12
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
13
|
+
|
|
14
|
+
// Hermetic config dir: point HOME at a temp dir BEFORE requiring the module, so
|
|
15
|
+
// web-sessions.json lands under a throwaway ~/.open-claudia.
|
|
16
|
+
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "oc-websess-"));
|
|
17
|
+
process.env.HOME = HOME;
|
|
18
|
+
|
|
19
|
+
const sessions = require("./core/web-sessions");
|
|
20
|
+
|
|
21
|
+
function cleanup() { try { fs.rmSync(HOME, { recursive: true, force: true }); } catch (_) {} }
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// issue() → a fresh 256-bit hex token that immediately validates.
|
|
25
|
+
const a = sessions.issue();
|
|
26
|
+
assert.strictEqual(typeof a, "string");
|
|
27
|
+
assert.match(a, /^[0-9a-f]{64}$/, "token is 32 random bytes hex");
|
|
28
|
+
assert.strictEqual(sessions.valid(a), true, "freshly issued token is valid");
|
|
29
|
+
|
|
30
|
+
// Distinct tokens per login; both live independently.
|
|
31
|
+
const b = sessions.issue();
|
|
32
|
+
assert.notStrictEqual(a, b, "each login mints a distinct token");
|
|
33
|
+
assert.ok(sessions.valid(a) && sessions.valid(b), "both sessions are live");
|
|
34
|
+
|
|
35
|
+
// Junk / empty / non-string never validates.
|
|
36
|
+
for (const bad of ["", "nope", null, undefined, 123, {}]) {
|
|
37
|
+
assert.strictEqual(sessions.valid(bad), false, `garbage token rejected: ${String(bad)}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Prototype-pollution keys must not slip through the object lookup.
|
|
41
|
+
for (const evil of ["__proto__", "constructor", "prototype", "hasOwnProperty", "toString"]) {
|
|
42
|
+
assert.strictEqual(sessions.valid(evil), false, `prototype key rejected: ${evil}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Persistence: the token is written to disk so it survives a bot restart.
|
|
46
|
+
const onDisk = JSON.parse(fs.readFileSync(sessions.SESSIONS_FILE, "utf-8"));
|
|
47
|
+
assert.ok(Object.prototype.hasOwnProperty.call(onDisk, a), "session persisted to disk");
|
|
48
|
+
assert.strictEqual(typeof onDisk[a].expires, "number", "persisted entry carries an expiry");
|
|
49
|
+
|
|
50
|
+
// Individual revoke: only the named token dies.
|
|
51
|
+
sessions.revoke(a);
|
|
52
|
+
assert.strictEqual(sessions.valid(a), false, "revoked token no longer validates");
|
|
53
|
+
assert.strictEqual(sessions.valid(b), true, "revoke is targeted, others survive");
|
|
54
|
+
|
|
55
|
+
// revokeAll: every session dies (the password-change semantics).
|
|
56
|
+
const c = sessions.issue();
|
|
57
|
+
assert.ok(sessions.valid(b) && sessions.valid(c), "sessions live before revokeAll");
|
|
58
|
+
sessions.revokeAll();
|
|
59
|
+
assert.ok(!sessions.valid(b) && !sessions.valid(c), "revokeAll kills every session");
|
|
60
|
+
|
|
61
|
+
// Expiry: an entry past its expires is swept and never validates.
|
|
62
|
+
const expired = { deadtok: { created: 1, expires: 1 } };
|
|
63
|
+
assert.strictEqual(sessions.sweep(expired), true, "sweep reports it dropped the expired entry");
|
|
64
|
+
assert.strictEqual(Object.keys(expired).length, 0, "expired entry removed");
|
|
65
|
+
fs.writeFileSync(sessions.SESSIONS_FILE, JSON.stringify({ oldtok: { created: 1, expires: Date.now() - 1000 } }));
|
|
66
|
+
assert.strictEqual(sessions.valid("oldtok"), false, "an expired persisted token is rejected");
|
|
67
|
+
|
|
68
|
+
cleanup();
|
|
69
|
+
console.log("web-sessions OK");
|
|
70
|
+
} catch (e) {
|
|
71
|
+
cleanup();
|
|
72
|
+
console.error(e);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
package/web.js
CHANGED
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
usageAlertPolicy,
|
|
11
11
|
usageTrend,
|
|
12
12
|
} = require("./core/usage-log");
|
|
13
|
+
const webSessions = require("./core/web-sessions");
|
|
13
14
|
|
|
14
15
|
const ENV_FILE = path.join(CONFIG_DIR, ".env");
|
|
15
16
|
const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
|
|
@@ -136,12 +137,7 @@ function checkAuth(req) {
|
|
|
136
137
|
const cookie = (req.headers.cookie || "").split(";").find((c) => c.trim().startsWith("oc_session="));
|
|
137
138
|
if (!cookie) return false;
|
|
138
139
|
const token = cookie.split("=")[1]?.trim();
|
|
139
|
-
|
|
140
|
-
return token === expected;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function authToken() {
|
|
144
|
-
return crypto.createHash("sha256").update(getPassword()).digest("hex");
|
|
140
|
+
return webSessions.valid(token);
|
|
145
141
|
}
|
|
146
142
|
|
|
147
143
|
// ── Config helpers ─────────────────────────────────────────────────
|
|
@@ -161,6 +157,26 @@ function saveEnv(env) {
|
|
|
161
157
|
fs.writeFileSync(ENV_FILE, content);
|
|
162
158
|
}
|
|
163
159
|
|
|
160
|
+
// GET /api/config used to return the raw .env — every bot token, model API key
|
|
161
|
+
// and control-plane secret in plaintext to anyone with a dashboard session. The
|
|
162
|
+
// config UI only ever edits the non-secret SAFE_KEYS, so mask any secret-looking
|
|
163
|
+
// value before it leaves the server (keeping a 4-char tail so the owner can still
|
|
164
|
+
// tell a value is set / which one it is). Masked values can never round-trip back
|
|
165
|
+
// in: the POST handler rejects every key outside SAFE_KEYS.
|
|
166
|
+
const SECRET_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|_KEY|CREDENTIAL|WEBHOOK|SESSION|PRIVATE)/i;
|
|
167
|
+
function maskEnv(env) {
|
|
168
|
+
const out = {};
|
|
169
|
+
for (const [k, v] of Object.entries(env)) {
|
|
170
|
+
if (v && SECRET_KEY_RE.test(k)) {
|
|
171
|
+
const s = String(v);
|
|
172
|
+
out[k] = s.length <= 8 ? "••••••••" : `••••${s.slice(-4)}`;
|
|
173
|
+
} else {
|
|
174
|
+
out[k] = v;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
164
180
|
function loadAuth() {
|
|
165
181
|
try { return JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")); } catch (e) { return { authorized: [], pending: [] }; }
|
|
166
182
|
}
|
|
@@ -255,7 +271,7 @@ async function handleAPI(req, res, body) {
|
|
|
255
271
|
if (password === getPassword()) {
|
|
256
272
|
res.writeHead(200, {
|
|
257
273
|
"Content-Type": "application/json",
|
|
258
|
-
"Set-Cookie": `oc_session=${
|
|
274
|
+
"Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
259
275
|
});
|
|
260
276
|
return res.end(JSON.stringify({ ok: true, mustChangePassword: !isPasswordChanged() }));
|
|
261
277
|
}
|
|
@@ -360,7 +376,7 @@ async function handleAPI(req, res, body) {
|
|
|
360
376
|
if (url === "/api/config") {
|
|
361
377
|
const env = loadEnv();
|
|
362
378
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
363
|
-
return res.end(JSON.stringify(env));
|
|
379
|
+
return res.end(JSON.stringify(maskEnv(env)));
|
|
364
380
|
}
|
|
365
381
|
|
|
366
382
|
// Update config (whitelist safe keys only)
|
|
@@ -502,9 +518,12 @@ async function handleAPI(req, res, body) {
|
|
|
502
518
|
return res.end(JSON.stringify({ error: complexityError }));
|
|
503
519
|
}
|
|
504
520
|
setPassword(newPassword);
|
|
521
|
+
// Changing the password invalidates every outstanding session, then we
|
|
522
|
+
// re-issue one so the caller who just changed it stays logged in.
|
|
523
|
+
webSessions.revokeAll();
|
|
505
524
|
res.writeHead(200, {
|
|
506
525
|
"Content-Type": "application/json",
|
|
507
|
-
"Set-Cookie": `oc_session=${
|
|
526
|
+
"Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
508
527
|
});
|
|
509
528
|
return res.end(JSON.stringify({ ok: true }));
|
|
510
529
|
}
|
|
@@ -1137,7 +1156,7 @@ function startWebServer() {
|
|
|
1137
1156
|
if (consume(token)) {
|
|
1138
1157
|
res.writeHead(302, {
|
|
1139
1158
|
"Location": "/",
|
|
1140
|
-
"Set-Cookie": `oc_session=${
|
|
1159
|
+
"Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
1141
1160
|
});
|
|
1142
1161
|
return res.end();
|
|
1143
1162
|
}
|