@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/bin/cli.js +13 -5
  3. package/bin/loopback-client.js +23 -5
  4. package/bin/owner-gate.js +2 -1
  5. package/bin/schedule.js +7 -2
  6. package/bin/tool.js +2 -2
  7. package/bot.js +6 -2
  8. package/channels/kazee/adapter.js +462 -64
  9. package/channels/kazee/state.js +78 -2
  10. package/channels/types.js +11 -5
  11. package/core/actions.js +6 -0
  12. package/core/approvals.js +9 -0
  13. package/core/bot-endpoint.js +194 -0
  14. package/core/context.js +11 -2
  15. package/core/handlers.js +161 -59
  16. package/core/identity.js +33 -3
  17. package/core/io.js +21 -8
  18. package/core/jobs.js +3 -0
  19. package/core/loopback.js +55 -7
  20. package/core/providers/claude.js +6 -2
  21. package/core/router.js +25 -13
  22. package/core/run-context.js +49 -8
  23. package/core/runner.js +313 -126
  24. package/core/scheduler.js +64 -32
  25. package/core/side-chat.js +40 -11
  26. package/core/speaker-attest.js +2 -0
  27. package/core/state.js +317 -50
  28. package/core/system-prompt.js +61 -14
  29. package/core/tool-guard.js +15 -0
  30. package/core/transcripts.js +23 -6
  31. package/package.json +3 -3
  32. package/test-approval-cards.js +24 -0
  33. package/test-call-speech.js +5 -3
  34. package/test-fixtures/fake-agent-cli.js +52 -1
  35. package/test-provider-compaction.js +50 -11
  36. package/test-provider-gateway.js +31 -10
  37. package/test-provider-language.js +15 -1
  38. package/test-provider-parity-e2e.js +8 -2
  39. package/test-provider-scheduler.js +77 -20
  40. package/test-provider-session-commands.js +11 -2
  41. package/test-provider-side-chat.js +30 -2
  42. package/test-run-lock.js +122 -39
  43. package/test-thread-lanes.js +530 -0
  44. package/core/queue-drain.js +0 -25
  45. package/test-queue-routing.js +0 -52
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.4
4
+
5
+ Parallel conversations in Kazee Chat threads, with the fixes from an independent review. Canary first, on the owner's own bot. (3.5.2 and 3.5.3 were tagged but never published: 3.5.2 failed its release test, 3.5.3 was cancelled for this build.)
6
+
7
+ - **A Kazee thread is its own conversation with the bot.** "Reply in thread" on any message starts a conversation that runs alongside the main chat and the other threads: its own session, transcript, queue, typing indicator and live preview, and the bot answers inside the thread. Up to 4 conversations run at once per person (`OC_MAX_PARALLEL_TURNS`) and 8 across the bot (`OC_MAX_PARALLEL_TURNS_GLOBAL`). Past that a message waits, says so, and starts when a slot frees. Inside one conversation turns still run one at a time.
8
+ - **A new thread starts "hybrid":** the message it hangs off, the replies so far, and the last 10 main-chat messages before it, not the whole history. `OC_THREAD_SEED=fresh` leaves the main messages out; `OC_THREAD_SEED=fork` (Claude) resumes the main conversation into the thread instead.
9
+ - **`/stop` stops only the conversation it is sent in, and drops the messages queued in it before the /stop,** naming them (`Cancelled. Dropped 2 queued: "reboot pe2", "and check the…"`). A conversation that is only waiting for a free slot drops its waiting messages the same way. `/stop all` does both for every conversation. A message sent after the /stop still runs. `/status` lists the conversations running now.
10
+ - **Everything a thread turn starts comes back to that thread:** wake-ups, watches and crons, files, photos and voice notes, approvals. A button pressed on a message the bot posted in a thread runs in that thread.
11
+ - **Groups:** a thread reply engages the bot when it is @mentioned, when the bot posted the thread's first message, or once the bot has replied in that thread. The main-chat catch-up leaves thread-only replies out.
12
+ - **A refused thread never loses a reply:** if Kazee Chat refuses the thread (for example its first message was deleted), the reply goes to the main chat, quoting that message.
13
+ - **Fixes from the review:** no typing indicator for a message that waits for a slot (it could stay on for good); a /stop during a compaction no longer eats the next message; in groups, a thread the bot replied in keeps engaging it even when the reply's id came back empty; a button pressed after a restart still runs in its thread; a job made in a forked thread never resumes the main conversation (`OC_THREAD_SEED=fork`); a job also waits for the conversation it replies into; a stuck run's late exit never frees a conversation another run holds; a turn can only post into its own thread; a Kazee send is never repeated because of bookkeeping; recall keeps a quiet conversation's own recent turns while other threads are busy.
14
+ - **Fix:** compaction read the wrong conversation's recent turns (the legacy "Workspace" transcript) when it summarised.
15
+ - **Changed:** background runs that have no chat now run beside chat conversations instead of queueing behind them, and count toward the caps.
16
+ - **Note:** this release also carries 3.5.0 and 3.5.1 (permission phase 0). The fleet's containers still need the bubblewrap fix for staff turns before any roll; this canary is for one bot on macOS, where the environment scrub works.
17
+
18
+ ## 3.5.1
19
+
20
+ Hardening after an adversarial review of 3.5.0, before any of it reaches the fleet. Canary first.
21
+
22
+ - **A CLI believes only its own bot about who it acts for.** `whoami` and the staff `tool-gate` answer are asked only of a loopback that the CLI process itself or one of its ancestors is listening on, and never of one started inside a staff turn. An address in `OC_SEND_URL` is no longer enough: in 3.5.0 a staff turn could run its own server that answered "owner", and the keyring and the other owner-only verbs believed it. Linux reads `/proc`; macOS asks `/bin/ps` and `/usr/sbin/lsof`. Anything that cannot be checked is unattributed, which is never the owner.
23
+ - **A staff member's watch check gets only the shell essentials.** It runs with an allowlist (PATH, HOME, user, locale, terminal, temp dirs, the config dir), its run stamp and `OC_GUARDED_RUN=1`. 3.5.0 dropped credentials by name pattern, so ones like `DATABASE_URL` or `MONGO_URI` still reached staff checks.
24
+ - **The owner's watch checks get the bot's environment again,** so an owner watch that reads a credential from env works as it did before 3.5.0.
25
+ - **A run with an incomplete chat origin is not the owner.** An origin that names a person or room but not the transport and room it came from counts as a non-owner. A run with no origin at all is still bot-internal work.
26
+ - **The staff shell screen is wider, and it is not a boundary.** A staff turn's shell command may not name process environments or argument blocks, the keyring, the bot's config dir, per-process `/proc` paths, or credential stores. Correction to 3.5.0: the shell does not reliably refuse credential reads. A staff turn that has a shell can still read what the bot's OS user can read. Phase 2 takes the shell away from staff turns.
27
+ - **The owner's routes outside a chat turn** (terminal, ssh, system cron), where 3.5.0 refuses owner-only work:
28
+ - the keyring: `/keyring set`, `/keyring get` and `/keyring remove` in the owner's chat;
29
+ - tool, pack, lessons, entity and ideas edits, and `open-claudia agent`: ask in the owner's chat;
30
+ - a write or destructive tool run: run it from a chat turn, or re-run it with the one-shot `--approval <id>` from a card the owner approved.
31
+ - **Tests:** the provider parity test names the suite that timed out and allows 180 s per suite. A loaded CI runner overran 90 s on canary-v3.5.0's first build.
32
+
3
33
  ## 3.5.0
4
34
 
5
35
  Security fixes for the permission system: phase 0 of the redesign in the owner's 25 Sep review. The owner's own chat turns work as before. Staff and external turns lose the ways around the checks. First shipped as a canary on two bots.
@@ -18,7 +48,7 @@ Security fixes for the permission system: phase 0 of the redesign in the owner's
18
48
  - entity, pack, lessons and ideas writes;
19
49
  - every keyring verb, including `list`;
20
50
  - `open-claudia agent`.
21
- From a plain terminal, these now need an owner approval token.
51
+ From a plain terminal these are refused; 3.5.1 lists the owner's routes.
22
52
  - **Follow-ups set in a staff turn run at that person's level, in that room.** This covers wakeups, crons and watches. The job's creator is the attested run. Before, it came from fields the run's own shell could forge, with a fallback to the owner. Watch checks run without the bot's secrets, as their creator.
23
53
  - **People notes and tasks are owner-only through the bot's commands.**
24
54
  - **History stays in the caller's room.** For a non-owner, `recent`, `transcript-search` and `transcript-window` return only the caller's room. `recent-fetch`'s channel filter, which matched nothing, is fixed.
@@ -29,7 +59,7 @@ Security fixes for the permission system: phase 0 of the redesign in the owner's
29
59
  - SSH, cloud and kube credentials;
30
60
  - other processes' environments.
31
61
  - Paths are resolved through symlinks.
32
- - The shell refuses the obvious equivalents.
62
+ - The shell screens a few common equivalents. This is not a boundary (corrected in 3.5.1).
33
63
  - A permission hook that cannot run now refuses on a staff turn instead of letting the tool through.
34
64
  - **Destructive is never a standing permission.**
35
65
  - "Always" on a destructive card is refused.
package/bin/cli.js CHANGED
@@ -56,18 +56,26 @@ function sendViaLoopback(kind, restArgs) {
56
56
  const stat = fs.statSync(filePath);
57
57
  const params = new URLSearchParams({ channelId, adapter: adapterId, fileName });
58
58
  if (process.env.OC_CHANNEL_USER_ID) params.set("userId", process.env.OC_CHANNEL_USER_ID);
59
+ // A thread turn's file goes back into its thread.
60
+ if (process.env.OC_THREAD_ID) params.set("threadId", process.env.OC_THREAD_ID);
59
61
  if (caption) params.set("caption", caption);
62
+ const headers = {
63
+ Authorization: `Bearer ${sendToken}`,
64
+ "Content-Type": "application/octet-stream",
65
+ "Content-Length": stat.size,
66
+ };
67
+ // The run's attest lets the bot check the thread against the live run.
68
+ if (process.env.OC_RUN_ID && process.env.OC_RUN_ATTEST) {
69
+ headers["X-OC-Run-Id"] = process.env.OC_RUN_ID;
70
+ headers["X-OC-Run-Attest"] = process.env.OC_RUN_ATTEST;
71
+ }
60
72
 
61
73
  const req = http.request({
62
74
  method: "POST",
63
75
  hostname: u.hostname,
64
76
  port: u.port,
65
77
  path: `/${kind}?${params.toString()}`,
66
- headers: {
67
- Authorization: `Bearer ${sendToken}`,
68
- "Content-Type": "application/octet-stream",
69
- "Content-Length": stat.size,
70
- },
78
+ headers,
71
79
  }, (res) => {
72
80
  let body = "";
73
81
  res.on("data", (c) => { body += c; });
@@ -7,6 +7,7 @@ const fs = require("fs");
7
7
  const path = require("path");
8
8
  const http = require("http");
9
9
  const configDir = require(path.join(__dirname, "..", "config-dir"));
10
+ const botEndpoint = require(path.join(__dirname, "..", "core", "bot-endpoint"));
10
11
 
11
12
  function readLoopbackInfo() {
12
13
  try {
@@ -40,6 +41,9 @@ function context() {
40
41
  sendUrl,
41
42
  sendToken,
42
43
  channelId: process.env.OC_CHANNEL_ID,
44
+ // The chat thread the turn is in (set by the runner for thread turns):
45
+ // files, voice and jobs from this turn go back into that thread.
46
+ threadId: process.env.OC_THREAD_ID || null,
43
47
  adapterId: process.env.OC_CHANNEL_ADAPTER,
44
48
  userId: process.env.OC_CHANNEL_USER_ID || null,
45
49
  canonicalUserId: process.env.OC_CANONICAL_USER_ID || null,
@@ -71,15 +75,26 @@ function requireContext() {
71
75
  return ctx;
72
76
  }
73
77
 
74
- function postJson(kind, body) {
78
+ // `verified: true` is for answers this process acts on (tool-gate): the
79
+ // request only goes to an endpoint served by this process's own bot (see
80
+ // core/bot-endpoint), never to whatever OC_SEND_URL names.
81
+ function postJson(kind, body, { verified = false } = {}) {
75
82
  const ctx = requireContext();
76
- const u = new URL(ctx.sendUrl);
83
+ let u;
84
+ if (verified) {
85
+ const check = botEndpoint.verifyEndpointCached(ctx.sendUrl);
86
+ if (!check.ok) return Promise.reject(new Error(`the bot's address could not be verified: ${check.reason}`));
87
+ u = new URL(check.url);
88
+ } else {
89
+ u = new URL(ctx.sendUrl);
90
+ }
77
91
  const requestedProject = body && body.project ? String(body.project) : ctx.project;
78
92
  const sameProject = !ctx.project || !requestedProject || requestedProject === ctx.project;
79
93
  const payload = JSON.stringify({
80
94
  ...body,
81
95
  ...(kind === "call-say" ? { callSpeechToken: process.env.OC_CALL_SPEECH_TOKEN || "" } : {}),
82
96
  channelId: ctx.channelId,
97
+ threadId: ctx.threadId,
83
98
  adapter: ctx.adapterId,
84
99
  userId: ctx.userId,
85
100
  canonicalUserId: ctx.canonicalUserId,
@@ -134,14 +149,17 @@ function postJson(kind, body) {
134
149
  // Who this process acts for, as the RUNNING BOT decides it. Needs no chat
135
150
  // context: a process with no live run behind it (stripped env, detached from
136
151
  // a finished turn, started outside the bot) comes back unattributed, and so
137
- // does every failure to ask. Unattributed is never the owner.
152
+ // does every failure to ask. Unattributed is never the owner. The question
153
+ // only goes to an endpoint served by this process's own bot — a server the
154
+ // env points at is not believed (core/bot-endpoint).
138
155
  const UNATTRIBUTED = Object.freeze({ attributed: false, owner: false, guarded: true, kind: "unattributed" });
139
156
 
140
157
  function whoami({ timeoutMs = 5000 } = {}) {
141
158
  const ctx = context();
142
159
  if (!ctx.runId || !ctx.runAttest || !ctx.sendUrl || !ctx.sendToken) return Promise.resolve(UNATTRIBUTED);
143
- let u;
144
- try { u = new URL(ctx.sendUrl); } catch (_) { return Promise.resolve(UNATTRIBUTED); }
160
+ const check = botEndpoint.verifyEndpointCached(ctx.sendUrl);
161
+ if (!check.ok) return Promise.resolve(Object.freeze({ ...UNATTRIBUTED, reason: check.reason }));
162
+ const u = new URL(check.url);
145
163
  const payload = JSON.stringify({ runId: ctx.runId, originRunId: ctx.runId, runAttest: ctx.runAttest });
146
164
  return new Promise((resolve) => {
147
165
  const req = http.request({
package/bin/owner-gate.js CHANGED
@@ -12,7 +12,8 @@ function hasRunAttest(env = process.env) {
12
12
  }
13
13
 
14
14
  function refusal(what, caller) {
15
- const why = !(caller && caller.attributed) ? "this process is not attributed to an owner chat turn"
15
+ const why = !(caller && caller.attributed)
16
+ ? `this process is not attributed to an owner chat turn${caller && caller.reason ? ` (${caller.reason})` : ""}`
16
17
  : caller.name ? `this turn acts for ${caller.name}, not the owner`
17
18
  : "this turn does not act for the owner";
18
19
  return `⛔ Owner only: ${what} — ${why}.`;
package/bin/schedule.js CHANGED
@@ -85,6 +85,11 @@ function jobProgress(j) {
85
85
  return state ? `${since}${state}` : "";
86
86
  }
87
87
 
88
+ // A job created in a chat thread fires back into that thread.
89
+ function threadMarker(j) {
90
+ return j.threadId ? ` (thread ${String(j.threadId).slice(-6)})` : "";
91
+ }
92
+
88
93
  function fmtJob(j) {
89
94
  const when = j.kind === "wakeup"
90
95
  ? `fires ${new Date(j.fireAt).toISOString()}`
@@ -92,7 +97,7 @@ function fmtJob(j) {
92
97
  const proj = j.project ? ` [${j.project}]` : "";
93
98
  const src = j.source ? ` (${j.source})` : "";
94
99
  const progress = jobProgress(j);
95
- return `${j.id} ${j.kind} ${j.label || "(no label)"}${proj}${src} ${when}${progress ? ` [${progress}]` : ""}`;
100
+ return `${j.id} ${j.kind} ${j.label || "(no label)"}${proj}${src}${threadMarker(j)} ${when}${progress ? ` [${progress}]` : ""}`;
96
101
  }
97
102
 
98
103
  function fmtWatch(w, now = Date.now()) {
@@ -104,7 +109,7 @@ function fmtWatch(w, now = Date.now()) {
104
109
  ? " EXPIRED (fires on next poll)"
105
110
  : ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
106
111
  : " no expiry (pre-timeout watch)";
107
- return `${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`;
112
+ return `${w.id} ${mode}${threadMarker(w)} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`;
108
113
  }
109
114
 
110
115
  async function runScheduleWakeup(args) {
package/bin/tool.js CHANGED
@@ -438,7 +438,7 @@ async function run(args) {
438
438
  const caller = await client.whoami();
439
439
  if (!caller.attributed) {
440
440
  if (!approvalId) {
441
- console.error(`⛔ ${t.name} (${gate.tier}) refused — not attributed to a live chat turn; write actions need a chat turn or an owner approval token.`);
441
+ console.error(`⛔ ${t.name} (${gate.tier}) refused — not attributed to a live chat turn; write actions need a chat turn or an owner approval token.${caller.reason ? ` (${caller.reason})` : ""}`);
442
442
  process.exitCode = 3; return;
443
443
  }
444
444
  } else if (!caller.owner && !approvalId) {
@@ -448,7 +448,7 @@ async function run(args) {
448
448
  if (!ctx.sendUrl || !ctx.sendToken || !ctx.channelId || !ctx.adapterId) throw new Error("no chat context to ask the bot from");
449
449
  decision = await client.postJson("tool-gate", {
450
450
  tool: t.name, verb: tools.invokedVerb(toolArgs), tier: gate.tier, command: commandLine,
451
- });
451
+ }, { verified: true });
452
452
  } catch (e) {
453
453
  decision = { decision: "blocked", reason: `the bot could not vet this action (${e.message})` };
454
454
  }
package/bot.js CHANGED
@@ -131,9 +131,13 @@ async function gracefulShutdown(signal) {
131
131
  console.error("memory shutdown drain failed:", error?.message);
132
132
  return { drained: false };
133
133
  });
134
+ // Every running conversation (main timeline and each thread) has its own
135
+ // process on its own lane.
134
136
  for (const state of userStates.values()) {
135
- if (state.runningProcess) {
136
- try { killProcessTree(state.runningProcess.pid, "SIGTERM"); } catch (e) {}
137
+ if (!(state.lanes instanceof Map)) continue;
138
+ for (const lane of state.lanes.values()) {
139
+ if (!lane.runningProcess) continue;
140
+ try { killProcessTree(lane.runningProcess.pid, "SIGTERM"); } catch (e) {}
137
141
  }
138
142
  }
139
143
  await Promise.all([sideCleanup, memoryCleanup]);