@inetafrica/open-claudia 3.5.0 → 3.5.4
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 +32 -2
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +23 -5
- package/bin/owner-gate.js +2 -1
- package/bin/schedule.js +7 -2
- package/bin/tool.js +2 -2
- package/bot.js +6 -2
- package/channels/kazee/adapter.js +462 -64
- package/channels/kazee/state.js +78 -2
- package/channels/types.js +11 -5
- package/core/actions.js +6 -0
- package/core/approvals.js +9 -0
- package/core/bot-endpoint.js +194 -0
- package/core/context.js +11 -2
- package/core/handlers.js +161 -59
- package/core/identity.js +33 -3
- package/core/io.js +21 -8
- package/core/jobs.js +3 -0
- package/core/loopback.js +55 -7
- package/core/providers/claude.js +6 -2
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +313 -126
- package/core/scheduler.js +64 -32
- package/core/side-chat.js +40 -11
- package/core/speaker-attest.js +2 -0
- package/core/state.js +317 -50
- package/core/system-prompt.js +61 -14
- package/core/tool-guard.js +15 -0
- package/core/transcripts.js +23 -6
- package/package.json +3 -3
- package/test-approval-cards.js +24 -0
- package/test-call-speech.js +5 -3
- package/test-fixtures/fake-agent-cli.js +52 -1
- package/test-provider-compaction.js +50 -11
- package/test-provider-gateway.js +31 -10
- package/test-provider-language.js +15 -1
- package/test-provider-parity-e2e.js +8 -2
- package/test-provider-scheduler.js +77 -20
- package/test-provider-session-commands.js +11 -2
- package/test-provider-side-chat.js +30 -2
- package/test-run-lock.js +122 -39
- package/test-thread-lanes.js +530 -0
- package/core/queue-drain.js +0 -25
- package/test-queue-routing.js +0 -52
package/channels/kazee/state.js
CHANGED
|
@@ -4,19 +4,29 @@
|
|
|
4
4
|
// "messages since I last engaged" delta and advances it on every engage, so
|
|
5
5
|
// group chatter the gate stayed silent through is caught up exactly once.
|
|
6
6
|
// Parent-process only; small, atomic writes.
|
|
7
|
+
//
|
|
8
|
+
// Chat threads ("reply in thread") keep their own small record, keyed by the
|
|
9
|
+
// thread's root message id: whether the bot has replied in it (a group thread
|
|
10
|
+
// the bot is part of engages it without an @-mention), who posted the root
|
|
11
|
+
// (fetched once, so the gate never asks twice), and the thread's own context
|
|
12
|
+
// watermark. Bounded: the least recently touched threads are dropped first.
|
|
7
13
|
|
|
8
14
|
const path = require("path");
|
|
9
15
|
const { CONFIG_DIR } = require("../../core/config");
|
|
10
16
|
const { atomicWriteFileSync, readJsonWithFallback } = require("../../core/fsutil");
|
|
11
17
|
|
|
12
18
|
const STATE_FILE = path.join(CONFIG_DIR, "kazee-state.json");
|
|
19
|
+
const MAX_THREADS = 2000;
|
|
13
20
|
|
|
14
21
|
let store = null;
|
|
15
22
|
|
|
16
23
|
function load() {
|
|
17
24
|
if (store) return store;
|
|
18
25
|
const raw = readJsonWithFallback(STATE_FILE, null) || {};
|
|
19
|
-
store = {
|
|
26
|
+
store = {
|
|
27
|
+
chats: raw.chats && typeof raw.chats === "object" ? raw.chats : {},
|
|
28
|
+
threads: raw.threads && typeof raw.threads === "object" && !Array.isArray(raw.threads) ? raw.threads : {},
|
|
29
|
+
};
|
|
20
30
|
return store;
|
|
21
31
|
}
|
|
22
32
|
|
|
@@ -39,4 +49,70 @@ function getContextWatermark(chatId) {
|
|
|
39
49
|
return (c && c.contextAt) || null;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
function getThread(rootId) {
|
|
53
|
+
const s = load();
|
|
54
|
+
const t = s.threads[String(rootId || "")];
|
|
55
|
+
return t && typeof t === "object" ? t : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Merge `patch` into the thread's record. Writes only when something changed,
|
|
59
|
+
// so the per-send "the bot replied here" mark costs one write per thread.
|
|
60
|
+
function updateThread(rootId, patch = {}) {
|
|
61
|
+
const id = String(rootId || "");
|
|
62
|
+
if (!id) return null;
|
|
63
|
+
const s = load();
|
|
64
|
+
const prev = s.threads[id] || {};
|
|
65
|
+
const changed = Object.keys(patch).some((k) => patch[k] !== undefined && prev[k] !== patch[k]);
|
|
66
|
+
if (!changed) return prev;
|
|
67
|
+
const next = { ...prev };
|
|
68
|
+
for (const [k, v] of Object.entries(patch)) if (v !== undefined) next[k] = v;
|
|
69
|
+
next.at = Date.now();
|
|
70
|
+
s.threads[id] = next;
|
|
71
|
+
const ids = Object.keys(s.threads);
|
|
72
|
+
if (ids.length > MAX_THREADS) {
|
|
73
|
+
ids.sort((a, b) => (Number(s.threads[a].at) || 0) - (Number(s.threads[b].at) || 0));
|
|
74
|
+
for (const old of ids.slice(0, ids.length - MAX_THREADS)) delete s.threads[old];
|
|
75
|
+
}
|
|
76
|
+
save();
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function markBotRepliedInThread(chatId, rootId) {
|
|
81
|
+
return updateThread(rootId, { chatId: String(chatId || ""), botReplied: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function botRepliedInThread(rootId) {
|
|
85
|
+
return getThread(rootId)?.botReplied === true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setThreadRootAuthor(chatId, rootId, authorId) {
|
|
89
|
+
if (!authorId) return null;
|
|
90
|
+
return updateThread(rootId, { chatId: String(chatId || ""), rootAuthor: String(authorId) });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getThreadRootAuthor(rootId) {
|
|
94
|
+
return getThread(rootId)?.rootAuthor || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setThreadWatermark(chatId, rootId, iso) {
|
|
98
|
+
if (!iso) return null;
|
|
99
|
+
return updateThread(rootId, { chatId: String(chatId || ""), contextAt: String(iso) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getThreadWatermark(rootId) {
|
|
103
|
+
return getThread(rootId)?.contextAt || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
setContextWatermark,
|
|
108
|
+
getContextWatermark,
|
|
109
|
+
getThread,
|
|
110
|
+
markBotRepliedInThread,
|
|
111
|
+
botRepliedInThread,
|
|
112
|
+
setThreadRootAuthor,
|
|
113
|
+
getThreadRootAuthor,
|
|
114
|
+
setThreadWatermark,
|
|
115
|
+
getThreadWatermark,
|
|
116
|
+
STATE_FILE,
|
|
117
|
+
MAX_THREADS,
|
|
118
|
+
};
|
package/channels/types.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* @property {string} type // "text" | "voice" | "audio" | "photo" | "document" | "action"
|
|
20
20
|
* @property {string} [text] // command text or message body
|
|
21
21
|
* @property {string} [messageId] // outer-channel message id
|
|
22
|
-
* @property {string} [replyToId] // id of message being replied to
|
|
22
|
+
* @property {string} [replyToId] // id of message being replied to (a quote, not a thread)
|
|
23
|
+
* @property {string} [threadId] // chat thread root id when the message was posted IN a thread
|
|
24
|
+
* // (Kazee "reply in thread"); null/absent = the main timeline.
|
|
25
|
+
* // A thread is its own conversation: own session, queue and lane.
|
|
23
26
|
* @property {Object} [reply] // {text, media?: MediaRef[], document, photo} for reply context
|
|
24
27
|
* @property {Array<MediaRef>} [media]
|
|
25
28
|
* @property {Object} [from] // {id, name, username}
|
|
@@ -38,6 +41,8 @@
|
|
|
38
41
|
* // (telegram-native, accepted by both adapters)
|
|
39
42
|
* // or {buttons:[PortableButton]} (portable)
|
|
40
43
|
* @property {string|number} [replyTo]
|
|
44
|
+
* @property {string} [threadId] // post into this chat thread (Kazee threadRootId); core/io
|
|
45
|
+
* // fills it from the turn's thread unless set explicitly
|
|
41
46
|
*
|
|
42
47
|
* @typedef {Object} CommandSpec
|
|
43
48
|
* @property {string} name // no leading slash
|
|
@@ -53,10 +58,11 @@
|
|
|
53
58
|
* send(channelId, text, opts): Promise<messageId|null>
|
|
54
59
|
* edit(channelId, messageId, text, opts): Promise<void>
|
|
55
60
|
* delete(channelId, messageId): Promise<void>
|
|
56
|
-
* sendVoice(channelId, oggPath): Promise<boolean>
|
|
57
|
-
* sendFile(channelId, filePath, caption?): Promise<boolean>
|
|
58
|
-
* typing(channelId, state?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
|
|
59
|
-
*
|
|
61
|
+
* sendVoice(channelId, oggPath, opts?): Promise<boolean> // opts.threadId: post into that thread
|
|
62
|
+
* sendFile(channelId, filePath, caption?, opts?): Promise<boolean> // opts.threadId: post into that thread
|
|
63
|
+
* typing(channelId, state?, opts?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
|
|
64
|
+
* // opts.threadId: show the indicator inside that thread
|
|
65
|
+
* typingStop?(channelId, opts?): Promise<void> // optional — socket channels only
|
|
60
66
|
* downloadMedia(mediaRef): Promise<localPath|null>
|
|
61
67
|
* buildThreadContext?(envelope): Promise<{contextBlock: string, media?: MediaRef[]}|null>
|
|
62
68
|
* registerCommands(commands: CommandSpec[]): Promise<void>
|
package/core/actions.js
CHANGED
|
@@ -622,6 +622,7 @@ async function handleAction(envelope) {
|
|
|
622
622
|
adapter: "",
|
|
623
623
|
adapterType: rec.adapter,
|
|
624
624
|
channelId: String(rec.channelId),
|
|
625
|
+
threadId: rec.threadId || null,
|
|
625
626
|
canonicalUserId: rec.canonicalUserId,
|
|
626
627
|
userId: rec.userId || null,
|
|
627
628
|
provider: rec.provider,
|
|
@@ -692,6 +693,9 @@ async function handleAction(envelope) {
|
|
|
692
693
|
adapter: isExternalAction ? "" : (rec.adapter || (adapter && adapter.id) || ""),
|
|
693
694
|
adapterType: isExternalAction ? (rec.adapter || (adapter && adapter.type)) : (adapter && adapter.type),
|
|
694
695
|
channelId: String(rec.channelId || envelope.channelId),
|
|
696
|
+
// Back into the thread the held run was in (the press itself may
|
|
697
|
+
// come from another room: the owner's approver channel).
|
|
698
|
+
threadId: rec.threadId || (rec.channelId ? null : envelope.threadId || null),
|
|
695
699
|
canonicalUserId: rec.canonicalUserId,
|
|
696
700
|
userId: rec.userId || null,
|
|
697
701
|
provider: rec.provider,
|
|
@@ -991,6 +995,8 @@ async function handleAction(envelope) {
|
|
|
991
995
|
adapter: envelope.adapter.id,
|
|
992
996
|
adapterType: envelope.adapter.type,
|
|
993
997
|
channelId: String(envelope.channelId),
|
|
998
|
+
// A preset picked in a thread fires in that thread.
|
|
999
|
+
threadId: envelope.threadId || null,
|
|
994
1000
|
canonicalUserId: envelope.canonicalUserId,
|
|
995
1001
|
project: cronProject,
|
|
996
1002
|
prompt: p.prompt,
|
package/core/approvals.js
CHANGED
|
@@ -19,6 +19,7 @@ const path = require("path");
|
|
|
19
19
|
const crypto = require("crypto");
|
|
20
20
|
const CONFIG_DIR = require("../config-dir");
|
|
21
21
|
const { atomicWriteFileSync } = require("./fsutil");
|
|
22
|
+
const { currentChannelId, currentThreadId } = require("./context");
|
|
22
23
|
|
|
23
24
|
const APPROVALS_DIR = process.env.APPROVALS_DIR || path.join(CONFIG_DIR, "approvals");
|
|
24
25
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
@@ -124,17 +125,25 @@ function create({
|
|
|
124
125
|
// room holds many people they are different values, so recording only the
|
|
125
126
|
// room would hand one member's grant to everyone in it.
|
|
126
127
|
speakerId = "", room = "",
|
|
128
|
+
// The chat thread of the ORIGIN turn (null = main timeline), so a re-run
|
|
129
|
+
// this approval wakes lands back in that thread. Unset: taken from the
|
|
130
|
+
// current turn when it is in the origin channel.
|
|
131
|
+
threadId = undefined,
|
|
127
132
|
} = {}) {
|
|
128
133
|
fs.mkdirSync(APPROVALS_DIR, { recursive: true, mode: 0o700 });
|
|
129
134
|
prune();
|
|
130
135
|
const id = `apr_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
|
|
131
136
|
const exactCommand = approvedCommand(command);
|
|
137
|
+
const originThread = threadId !== undefined
|
|
138
|
+
? threadId
|
|
139
|
+
: (currentThreadId() && String(currentChannelId() || "") === String(channelId || "") ? currentThreadId() : null);
|
|
132
140
|
const rec = {
|
|
133
141
|
id, status: "pending",
|
|
134
142
|
tool: String(tool || ""), verb: String(verb || ""), tier: String(tier || ""),
|
|
135
143
|
command: exactCommand,
|
|
136
144
|
commandHash: commandHash(exactCommand),
|
|
137
145
|
channelId: String(channelId || ""), adapter: String(adapter || ""),
|
|
146
|
+
threadId: originThread ? String(originThread) : null,
|
|
138
147
|
canonicalUserId: String(canonicalUserId || ""), userId: String(userId || ""),
|
|
139
148
|
provider: String(provider || ""), providerSettings: providerSettings && typeof providerSettings === "object" ? structuredClone(providerSettings) : {},
|
|
140
149
|
project: String(project || ""), projectDir: String(projectDir || ""),
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Which loopback endpoint may a CLI believe about WHO it acts for?
|
|
2
|
+
//
|
|
3
|
+
// Owner-only CLI verbs and staff tool writes are decided by asking the bot
|
|
4
|
+
// (whoami, tool-gate). The address to ask comes from this process's env
|
|
5
|
+
// (OC_SEND_URL), and a staff turn's shell can point that at a server of its
|
|
6
|
+
// own that answers "owner" (Phase 0 review, CRITICAL). So an endpoint is
|
|
7
|
+
// believed for those answers only when:
|
|
8
|
+
// 1. the process LISTENING on it is this process or one of its ancestors —
|
|
9
|
+
// the bot spawns every run, so the real bot is always above the CLI,
|
|
10
|
+
// while a server a turn starts is beside it at best; and
|
|
11
|
+
// 2. nothing above that listener was STARTED with OC_GUARDED_RUN=1 in its
|
|
12
|
+
// environment — the listener was not itself started inside a staff turn
|
|
13
|
+
// (such a server could run the CLI as its own child, making itself an
|
|
14
|
+
// ancestor). A process's start-up environment is fixed by whoever spawned
|
|
15
|
+
// it; a turn's shell cannot rewrite its provider's.
|
|
16
|
+
// Linux reads /proc; macOS asks /bin/ps and /usr/sbin/lsof by absolute path
|
|
17
|
+
// (PATH belongs to the caller). Anything that cannot be established is a
|
|
18
|
+
// refusal, and a refused caller is unattributed — never the owner.
|
|
19
|
+
//
|
|
20
|
+
// Residual, closed by Phase 2's tool-only staff turns: a staff shell that
|
|
21
|
+
// detaches a server from its turn and runs the CLI beneath that server is no
|
|
22
|
+
// longer below the guarded provider, so rule 2 cannot see it.
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const { execFileSync } = require("child_process");
|
|
26
|
+
|
|
27
|
+
const MAX_DEPTH = 64;
|
|
28
|
+
const PS = "/bin/ps";
|
|
29
|
+
const LSOF = "/usr/sbin/lsof";
|
|
30
|
+
const EXEC_TIMEOUT_MS = 5000;
|
|
31
|
+
|
|
32
|
+
function parsePort(url) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try { parsed = new URL(String(url)); } catch (_) { return null; }
|
|
35
|
+
if (parsed.protocol !== "http:") return null;
|
|
36
|
+
if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") return null;
|
|
37
|
+
const port = Number(parsed.port);
|
|
38
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Linux: /proc ─────────────────────────────────────────────────────────────
|
|
42
|
+
// The loopback binds 127.0.0.1 itself, never a wildcard, and nothing else can
|
|
43
|
+
// bind that exact address and port while it listens (no SO_REUSEPORT), so the
|
|
44
|
+
// only listener that counts is an IPv4 127.0.0.1 socket. /proc/net/tcp shows
|
|
45
|
+
// the address as little-endian hex.
|
|
46
|
+
const LINUX_LOOPBACK_HEX = "0100007F";
|
|
47
|
+
|
|
48
|
+
function linuxSystem(procRoot = "/proc") {
|
|
49
|
+
return {
|
|
50
|
+
parentOf(pid) {
|
|
51
|
+
const stat = fs.readFileSync(`${procRoot}/${pid}/stat`, "utf8");
|
|
52
|
+
// "pid (comm) state ppid ..." — comm may hold spaces and parentheses.
|
|
53
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
54
|
+
const ppid = Number(fields[1]);
|
|
55
|
+
return Number.isInteger(ppid) ? ppid : null;
|
|
56
|
+
},
|
|
57
|
+
// The pid in `pids` that owns the 127.0.0.1:port listening socket, else null.
|
|
58
|
+
listenerAmong(port, pids) {
|
|
59
|
+
const inodes = new Set();
|
|
60
|
+
const text = fs.readFileSync(`${procRoot}/net/tcp`, "utf8");
|
|
61
|
+
for (const line of text.split("\n").slice(1)) {
|
|
62
|
+
const cols = line.trim().split(/\s+/);
|
|
63
|
+
if (cols.length < 10 || cols[3] !== "0A") continue; // 0A = LISTEN
|
|
64
|
+
const [addr, portHex] = cols[1].split(":");
|
|
65
|
+
if (addr === LINUX_LOOPBACK_HEX && parseInt(portHex, 16) === port) inodes.add(cols[9]);
|
|
66
|
+
}
|
|
67
|
+
if (!inodes.size) return null;
|
|
68
|
+
for (const pid of pids) {
|
|
69
|
+
let fds = [];
|
|
70
|
+
try { fds = fs.readdirSync(`${procRoot}/${pid}/fd`); } catch (_) { continue; }
|
|
71
|
+
for (const fd of fds) {
|
|
72
|
+
let link = "";
|
|
73
|
+
try { link = fs.readlinkSync(`${procRoot}/${pid}/fd/${fd}`); } catch (_) { continue; }
|
|
74
|
+
const match = /^socket:\[(\d+)\]$/.exec(link);
|
|
75
|
+
if (match && inodes.has(match[1])) return pid;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
},
|
|
80
|
+
startEnvHas(pid, key, value) {
|
|
81
|
+
const raw = fs.readFileSync(`${procRoot}/${pid}/environ`, "utf8");
|
|
82
|
+
return raw.split("\0").includes(`${key}=${value}`);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── macOS: /bin/ps + /usr/sbin/lsof ─────────────────────────────────────────
|
|
88
|
+
function run(file, args) {
|
|
89
|
+
return execFileSync(file, args, {
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
92
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
93
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
94
|
+
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "C", LC_ALL: "C" },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function darwinSystem() {
|
|
99
|
+
let parents = null;
|
|
100
|
+
return {
|
|
101
|
+
parentOf(pid) {
|
|
102
|
+
if (!parents) {
|
|
103
|
+
parents = new Map();
|
|
104
|
+
for (const line of run(PS, ["-Ao", "pid=,ppid="]).split("\n")) {
|
|
105
|
+
const [p, pp] = line.trim().split(/\s+/).map(Number);
|
|
106
|
+
if (Number.isInteger(p) && Number.isInteger(pp)) parents.set(p, pp);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return parents.has(pid) ? parents.get(pid) : null;
|
|
110
|
+
},
|
|
111
|
+
listenerAmong(port, pids) {
|
|
112
|
+
let out = "";
|
|
113
|
+
try { out = run(LSOF, ["-nP", "-a", "-p", pids.join(","), `-iTCP@127.0.0.1:${port}`, "-sTCP:LISTEN", "-Fpn"]); }
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Exit 1 whenever some listed pid matched nothing (or is another
|
|
116
|
+
// user's); what did match is still on stdout.
|
|
117
|
+
if (!error || error.status !== 1) throw error;
|
|
118
|
+
out = String(error.stdout || "");
|
|
119
|
+
}
|
|
120
|
+
const owners = new Set();
|
|
121
|
+
let current = null;
|
|
122
|
+
for (const line of out.split("\n")) {
|
|
123
|
+
if (line.startsWith("p")) current = Number(line.slice(1));
|
|
124
|
+
else if (line === `n127.0.0.1:${port}` && current != null) owners.add(current);
|
|
125
|
+
}
|
|
126
|
+
for (const pid of pids) if (owners.has(pid)) return pid;
|
|
127
|
+
return null;
|
|
128
|
+
},
|
|
129
|
+
// ps -E prints the command line followed by the environment the process
|
|
130
|
+
// started with; the environment is what follows the plain command line.
|
|
131
|
+
startEnvHas(pid, key, value) {
|
|
132
|
+
const withEnv = run(PS, ["-E", "-ww", "-o", "command=", "-p", String(pid)]).replace(/\n+$/, "");
|
|
133
|
+
const bare = run(PS, ["-ww", "-o", "command=", "-p", String(pid)]).replace(/\n+$/, "");
|
|
134
|
+
if (!withEnv.startsWith(bare)) return false;
|
|
135
|
+
return withEnv.slice(bare.length).split(" ").includes(`${key}=${value}`);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function currentSystem() {
|
|
141
|
+
if (process.platform === "linux") return linuxSystem();
|
|
142
|
+
if (process.platform === "darwin") return darwinSystem();
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ancestry(pid, system) {
|
|
147
|
+
const chain = [pid];
|
|
148
|
+
let current = pid;
|
|
149
|
+
while (chain.length < MAX_DEPTH) {
|
|
150
|
+
const parent = system.parentOf(current);
|
|
151
|
+
if (!Number.isInteger(parent) || parent <= 0 || chain.includes(parent)) break;
|
|
152
|
+
chain.push(parent);
|
|
153
|
+
current = parent;
|
|
154
|
+
}
|
|
155
|
+
return chain;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function refuse(reason) {
|
|
159
|
+
return { ok: false, reason };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// { ok: true, url, port, listenerPid } when `url` may be believed about who
|
|
163
|
+
// this process acts for; { ok: false, reason } otherwise.
|
|
164
|
+
function verifyEndpoint(url, { pid = process.pid, system = currentSystem() } = {}) {
|
|
165
|
+
const port = parsePort(url);
|
|
166
|
+
if (!port) return refuse("the loopback address is not a 127.0.0.1 endpoint");
|
|
167
|
+
if (!system) return refuse(`cannot inspect processes on ${process.platform}`);
|
|
168
|
+
let chain;
|
|
169
|
+
let listener;
|
|
170
|
+
try {
|
|
171
|
+
chain = ancestry(pid, system);
|
|
172
|
+
listener = system.listenerAmong(port, chain);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return refuse(`cannot read the process tree (${error && error.message ? error.message : error})`);
|
|
175
|
+
}
|
|
176
|
+
if (listener == null) return refuse("the loopback endpoint is not served by this process or an ancestor");
|
|
177
|
+
for (const above of chain.slice(chain.indexOf(listener) + 1)) {
|
|
178
|
+
let guarded = false;
|
|
179
|
+
// Unreadable = another user's process, never a guarded provider (the bot
|
|
180
|
+
// starts providers as its own user).
|
|
181
|
+
try { guarded = system.startEnvHas(above, "OC_GUARDED_RUN", "1") === true; } catch (_) { guarded = false; }
|
|
182
|
+
if (guarded) return refuse("the loopback endpoint was started inside a guarded (non-owner) run");
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, url: `http://127.0.0.1:${port}`, port, listenerPid: listener };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const verified = new Map();
|
|
188
|
+
function verifyEndpointCached(url) {
|
|
189
|
+
const key = String(url || "");
|
|
190
|
+
if (!verified.has(key)) verified.set(key, verifyEndpoint(url));
|
|
191
|
+
return verified.get(key);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { verifyEndpoint, verifyEndpointCached, parsePort, linuxSystem, darwinSystem, ancestry };
|
package/core/context.js
CHANGED
|
@@ -6,8 +6,8 @@ const { AsyncLocalStorage } = require("node:async_hooks");
|
|
|
6
6
|
|
|
7
7
|
const chatContext = new AsyncLocalStorage();
|
|
8
8
|
|
|
9
|
-
function runInChat({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup }, fn) {
|
|
10
|
-
return chatContext.run({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup: !!isGroup }, fn);
|
|
9
|
+
function runInChat({ adapter, channelId, threadId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup }, fn) {
|
|
10
|
+
return chatContext.run({ adapter, channelId, threadId: threadId ? String(threadId) : null, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup: !!isGroup }, fn);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
function currentStore() {
|
|
@@ -24,6 +24,14 @@ function currentChannelId() {
|
|
|
24
24
|
return s ? s.channelId : null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// The chat thread this turn belongs to (a Kazee thread's root message id), or
|
|
28
|
+
// null for the channel's main timeline. A thread is its own conversation: its
|
|
29
|
+
// own session, transcript, queue and run lane, and every reply goes back into it.
|
|
30
|
+
function currentThreadId() {
|
|
31
|
+
const s = currentStore();
|
|
32
|
+
return s && s.threadId ? s.threadId : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
function currentCanonicalUserId() {
|
|
28
36
|
const s = currentStore();
|
|
29
37
|
return s ? s.canonicalUserId : null;
|
|
@@ -114,6 +122,7 @@ module.exports = {
|
|
|
114
122
|
runInChat,
|
|
115
123
|
currentAdapter,
|
|
116
124
|
currentChannelId,
|
|
125
|
+
currentThreadId,
|
|
117
126
|
currentCanonicalUserId,
|
|
118
127
|
currentUserId,
|
|
119
128
|
currentTransport,
|