@inetafrica/open-claudia 3.0.27 → 3.0.29

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.29 — The owner actually gets the approval request
4
+
5
+ - **The external-speaker guardrail no longer swallows approval requests.** When a non-owner talks to a bot and the guard holds the reply, the bot escalates to the owner with Approve/Deny buttons and tells the external "let me check with the owner." That escalation resolved the owner's channel through `relationship.ownerTarget()`, which read **only** `people.owners()`. On a bot seeded from legacy `auth.json` the people store is empty, so it returned no owner, escalation failed with "no reachable owner," and the owner got **nothing** — while the external was still told a follow-up was coming. (Seen live: `kazee-people` deferred to the owner in a Kazee Spaces thread and no approval ever arrived.)
6
+ - **`ownerTarget()` now falls back to the configured-owner env identity** — the same operator-declared signal the guardrail already trusts *inbound* (`identity.isConfiguredOwnerChannel`: Telegram `TELEGRAM_CHAT_ID`, Kazee/Spaces `KAZEE_OWNER_USER_ID`). When no people-record handle exists it derives a deliverable target from env, preferring Telegram (native buttons, and the chat id is the send target) and otherwise resolving the owner's Kazee 1:1 DM via the adapter's idempotent `openDirectChat` (a Kazee send target is a chat-document id, not a user id). Only channels actually loaded on the bot are considered; `ownerTarget()` is now async.
7
+ - **Fail-closed contract preserved.** If no configured owner channel is reachable the guard still blocks (returns no target) — the fix only *restores* the owner's approval prompt, it never widens what an external can do. Loopback's Spaces-approval binding gets the same fallback. No autonomy change; B2+ and Track A stay gated.
8
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
9
+
10
+ ## v3.0.28 — /upgrade stops crying wolf on a slow roll
11
+
12
+ - **A client-side `/upgrade` timeout is no longer reported as a failure.** When the bot runs inside an AgentSpace pod, `/upgrade` calls the control plane, which triggers a fire-and-forget rollout and returns 202. If the pod is mid-roll its own in-flight request gets cut off, so the 15s HTTP wait would expire and the bot announced "Upgrade request failed … timeout" — a false negative that invited repeated re-runs (and a storm of redundant rollouts, seen as 11 ReplicaSets in 7 minutes). A timed-out request now surfaces as **pending** ("sent but the reply timed out — likely because I'm already restarting; give it a minute"), and the request timeout is raised 15s → 30s.
13
+ - **Pairs with a control-plane probe fix (the actual root cause).** The pod's `/health` probes carried no `timeoutSeconds`, so k8s defaulted it to 1s; a cold-start event-loop stall past 1s flapped the liveness probe and SIGTERM'd the pod in a restart loop. That fix — explicit `timeoutSeconds: 5` on every probe and liveness `failureThreshold` 3 → 5 — ships in the agent-space control plane and reaches existing pods on their next reconcile (`/cluster sync`).
14
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
15
+
3
16
  ## v3.0.27 — Agency ledger, advisory "what's next", owner-guardrail capture, and a reboot that waits for the turn to end
4
17
 
5
18
  - **`/agenda` — one read-only work list across everything (Track B0).** A new aggregator normalises your persistent tasks, `spaces mine`, and connector inbox/notifications into a single list (source, title, status, due, staleness, blocked-on, last-touched). Pure visibility — it reads, it never acts. `/agency` is an alias.
package/core/enforcer.js CHANGED
@@ -293,7 +293,7 @@ async function escalate({
293
293
  const approvals = require("./approvals");
294
294
  const relationship = require("./relationship");
295
295
  const audit = safeAudit();
296
- const owner = relationship.ownerTarget();
296
+ const owner = await relationship.ownerTarget();
297
297
  if (!owner) {
298
298
  if (audit) audit.log("enforcer.escalate.failed", { reason: "no reachable owner", guardKind: kind, person: speaker && speaker.name });
299
299
  return { ok: false, escalated: false, reason: "no reachable owner to approve" };
package/core/handlers.js CHANGED
@@ -624,7 +624,7 @@ async function requestAgentSpaceUpgrade() {
624
624
  res.on("end", () => resolve({ status: res.statusCode, body: data }));
625
625
  });
626
626
  req.on("error", (e) => resolve({ status: 0, body: String(e.message || e) }));
627
- req.setTimeout(15000, () => { req.destroy(new Error("timeout")); });
627
+ req.setTimeout(30000, () => { req.destroy(new Error("timeout")); });
628
628
  req.end();
629
629
  });
630
630
  }
@@ -678,6 +678,14 @@ register({
678
678
  await send("Upgrade requested — AgentSpace will pull latest image and restart, see you in a minute.");
679
679
  return;
680
680
  }
681
+ // A client-side timeout is not a failure: the control plane triggers the
682
+ // rollout fire-and-forget, and a pod being rolled is exactly when its own
683
+ // in-flight request gets cut off. Framing it as "failed" invites a retry
684
+ // storm of redundant rollouts, so surface it as pending instead.
685
+ if (result && result.status === 0 && /timeout/i.test(result.body || "")) {
686
+ await send("Upgrade request sent but the reply timed out — likely because I'm already restarting. Give it a minute before re-running; I should come back on the new image.");
687
+ return;
688
+ }
681
689
  await send(`Upgrade request failed (status ${result?.status || "?"}). ${(result?.body || "").slice(0, 300)}`);
682
690
  return;
683
691
  } catch (e) {
package/core/loopback.js CHANGED
@@ -533,7 +533,7 @@ async function handleJson(req, res, url, kind) {
533
533
  let approverChannel = "";
534
534
  const spacesOrigin = adapter.type === "spaces";
535
535
  if (spacesOrigin) {
536
- const owner = require("./relationship").ownerTarget();
536
+ const owner = await require("./relationship").ownerTarget();
537
537
  const oa = owner
538
538
  ? (registry.findAdapter(owner.adapter) || registry.getAdapters().find((a) => a.type === owner.adapter))
539
539
  : null;
@@ -108,27 +108,63 @@ function isCurrentSpeakerGuarded() {
108
108
  return guardActive(currentSpeaker());
109
109
  }
110
110
 
111
+ // Fallback owner target derived from the operator-declared .env identity — the
112
+ // SAME trusted signal identity.isConfiguredOwnerChannel already accepts INBOUND.
113
+ // This closes the resolution asymmetry that silently swallowed approval
114
+ // requests: a bot seeded from legacy auth has an empty people store, so the
115
+ // people-record path below finds no owner handle and escalation failed with
116
+ // "no reachable owner" even though the owner was reachable on a configured
117
+ // channel. Prefers Telegram (the owner's native-button channel, and the send
118
+ // target IS the chat id) and falls back to Kazee, whose send target is a
119
+ // chat-document id — so the owner's user id is resolved to a 1:1 DM via the
120
+ // adapter's idempotent openDirectChat. Only channels actually loaded on THIS
121
+ // bot are considered. person is null (no record), which downstream tolerates
122
+ // (enforcer.ownerFirstName → "the owner"). Returns null when no configured owner
123
+ // channel is reachable, preserving the fail-closed contract.
124
+ async function configuredOwnerTarget() {
125
+ let cfg, registry;
126
+ try { cfg = require("./config"); registry = require("./adapter-registry"); }
127
+ catch (e) { return null; }
128
+ const adapters = registry.getAdapters();
129
+ const loaded = (type) => adapters.find((a) => a.type === type) || null;
130
+
131
+ const tgChatId = (cfg.CHAT_IDS || [])[0];
132
+ if (tgChatId && loaded("telegram")) {
133
+ return { adapter: "telegram", channelId: String(tgChatId), person: null };
134
+ }
135
+
136
+ const kzOwner = cfg.config && cfg.config.KAZEE_OWNER_USER_ID;
137
+ const kazee = loaded("kazee");
138
+ if (kzOwner && kazee && typeof kazee.openDirectChat === "function") {
139
+ try {
140
+ const chatId = await kazee.openDirectChat(String(kzOwner));
141
+ if (chatId) return { adapter: "kazee", channelId: String(chatId), person: null };
142
+ } catch (e) { /* outreach unavailable → fall through to no owner */ }
143
+ }
144
+ return null;
145
+ }
146
+
111
147
  // The owner's approval channel — where escalation buttons are posted. Resolves
112
- // the first owner's primary (or first) handle. null if there is no reachable
113
- // owner handle, in which case escalation cannot proceed and the caller must
114
- // fail closed (block).
115
- function ownerTarget() {
148
+ // the first owner's primary (or first) people-record handle, then falls back to
149
+ // the configured-owner env identity. null only if there is no reachable owner
150
+ // channel at all, in which case escalation cannot proceed and the caller must
151
+ // fail closed (block). Async because the Kazee fallback resolves a DM over the
152
+ // network.
153
+ async function ownerTarget() {
116
154
  try {
117
155
  const people = require("./people");
118
156
  const owner = (people.owners() || [])[0];
119
- if (!owner) return null;
120
- const handles = owner.handles || [];
121
- if (!handles.length) return null;
122
- const primary = owner.primaryChannel
123
- ? handles.find((h) => h.adapter === owner.primaryChannel.adapter &&
124
- String(h.channelId) === String(owner.primaryChannel.channelId))
125
- : null;
126
- const handle = primary || handles[0];
127
- if (!handle) return null;
128
- return { adapter: handle.adapter, channelId: String(handle.channelId), person: owner };
129
- } catch (e) {
130
- return null;
131
- }
157
+ if (owner) {
158
+ const handles = owner.handles || [];
159
+ const primary = owner.primaryChannel
160
+ ? handles.find((h) => h.adapter === owner.primaryChannel.adapter &&
161
+ String(h.channelId) === String(owner.primaryChannel.channelId))
162
+ : null;
163
+ const handle = primary || handles[0];
164
+ if (handle) return { adapter: handle.adapter, channelId: String(handle.channelId), person: owner };
165
+ }
166
+ } catch (e) { /* fall through to the env fallback */ }
167
+ return configuredOwnerTarget();
132
168
  }
133
169
 
134
- module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget };
170
+ module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, configuredOwnerTarget };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.27",
3
+ "version": "3.0.29",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
package/test-enforcer.js CHANGED
@@ -19,6 +19,12 @@ process.env.ENTITIES_DIR = path.join(tmp, "entities");
19
19
  process.env.PEOPLE_FILE = path.join(tmp, "people.json");
20
20
  process.env.APPROVALS_DIR = path.join(tmp, "approvals");
21
21
  fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
22
+ // Configured-owner env identity for the ownerTarget fallback test below. No
23
+ // TELEGRAM_CHAT_ID → the telegram branch is skipped and the fallback resolves
24
+ // the owner's Kazee DM (the real Kazee-only-bot scenario). Captured at config
25
+ // load, so it MUST be set before any core module (which pulls in config) loads.
26
+ delete process.env.TELEGRAM_CHAT_ID;
27
+ process.env.KAZEE_OWNER_USER_ID = "owner-kz-999";
22
28
 
23
29
  // ── stub the model (destructured at enforcer load) and the transport (lazily
24
30
  // required in adapterFor) BEFORE requiring the enforcer. ──────────────────
@@ -47,10 +53,22 @@ const fakeAdapter = {
47
53
  type: "telegram", id: "telegram",
48
54
  send: async (channelId, text, opts) => { sends.push({ channelId: String(channelId), text, opts: opts || {} }); return { message_id: sends.length }; },
49
55
  };
56
+ // A loaded Kazee adapter whose openDirectChat resolves the owner's user id to a
57
+ // 1:1 DM chat-document id — the send target the ownerTarget env fallback needs.
58
+ let kazeeDmRequestedFor = null;
59
+ const fakeKazee = {
60
+ type: "kazee", id: "kazee",
61
+ send: async (channelId, text, opts) => { sends.push({ channelId: String(channelId), text, opts: opts || {}, adapter: "kazee" }); return { message_id: sends.length }; },
62
+ openDirectChat: async (userId) => { kazeeDmRequestedFor = String(userId); return "dm-chat-777"; },
63
+ };
64
+ const registryAdapters = [fakeAdapter, fakeKazee];
50
65
  const registryPath = require.resolve("./core/adapter-registry");
51
66
  require.cache[registryPath] = {
52
67
  id: registryPath, filename: registryPath, loaded: true,
53
- exports: { findAdapter: (id) => (id === "telegram" ? fakeAdapter : null), getAdapters: () => [fakeAdapter] },
68
+ exports: {
69
+ findAdapter: (id) => registryAdapters.find((a) => a.id === id) || null,
70
+ getAdapters: () => registryAdapters.slice(),
71
+ },
54
72
  };
55
73
 
56
74
  const people = require("./core/people");
@@ -209,5 +227,19 @@ const extSpeaker = relationship.speakerFor("telegram", CH_EXT);
209
227
  assert.ok(!auditRaw.includes("hunter2"), "blocked reply content never enters the audit log");
210
228
  assert.ok(!auditRaw.includes("May only discuss invoicing status"), "owner-authored mandate never enters the audit log");
211
229
 
230
+ // ── ownerTarget env fallback: a bot with an empty people store still reaches
231
+ // the owner via the configured-owner env identity. This is the fix for the
232
+ // silently-swallowed approval request — the people-record path finds no owner
233
+ // handle, so escalation used to fail "no reachable owner" even though the
234
+ // owner was reachable on a configured channel. Telegram is skipped (no
235
+ // TELEGRAM_CHAT_ID), so it resolves the owner's Kazee 1:1 DM. ──
236
+ kazeeDmRequestedFor = null;
237
+ const fbTarget = await relationship.configuredOwnerTarget();
238
+ assert.ok(fbTarget, "configuredOwnerTarget resolves an owner channel from env");
239
+ assert.strictEqual(fbTarget.adapter, "kazee", "falls back to the configured Kazee owner");
240
+ assert.strictEqual(fbTarget.channelId, "dm-chat-777", "resolves the owner's 1:1 DM chat id, not the raw user id");
241
+ assert.strictEqual(fbTarget.person, null, "env-derived owner target carries no people record");
242
+ assert.strictEqual(kazeeDmRequestedFor, "owner-kz-999", "the DM is opened for the configured owner user id");
243
+
212
244
  console.log("enforcer OK");
213
245
  })().catch((e) => { console.error(e); process.exit(1); });
@@ -32,18 +32,19 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.27 approved by Sumeet 2026-07-15 ("work through till it's done and push it
36
- // out"): the non-gated architecture batch. (1) X0 — always-on prompt-budget
37
- // instrumentation: measure the fixed floor vs per-turn recall payload, telemetry
38
- // only (no prompt change, no trim). (2) B0 /agenda read-only work ledger
39
- // (tasks + Spaces + notifications). (3) B1 — /next advisory deliberation that
40
- // proposes a pick and acts on nothing. (4) C0/C1owner-guardrail capture:
41
- // "from now on / never / stop" statements become always-loaded lessons (asked or
42
- // auto-captured with announce + one-tap undo), owner-only, never from externals.
43
- // (5) Turn-aware reboot: the Telegram 10-min poll-wedge backstop defers its
44
- // self-exit while any turn is running/queued so a restart never interrupts
45
- // in-flight work. B2+ autonomy and Track A stay gated.
46
- assert.strictEqual(pkg.version, "3.0.27", "release version must remain unchanged without explicit approval");
35
+ // 3.0.29 approved by Sumeet 2026-07-15 ("Yes fix"): closes an owner-resolution
36
+ // asymmetry in the external-speaker guardrail that silently swallowed approval
37
+ // requests. Inbound, identity.isConfiguredOwnerChannel recognises the owner via
38
+ // the operator-declared .env identity (telegram CHAT_IDS, kazee/spaces
39
+ // KAZEE_OWNER_USER_ID). Outbound, relationship.ownerTarget() read ONLY
40
+ // people.owners() empty on bots seeded from legacy authso escalation failed
41
+ // "no reachable owner" and the external was told the bot would follow up while
42
+ // the owner got NOTHING. ownerTarget() now falls back to the SAME configured
43
+ // env identity when no people handle exists, resolving the owner's Kazee 1:1 DM
44
+ // (via adapter.openDirectChat) or Telegram chat. Now async. No autonomy change:
45
+ // it only restores the owner's approval prompt; the guard still fails closed
46
+ // (blocks) when no owner channel is reachable. B2+ and Track A stay gated.
47
+ assert.strictEqual(pkg.version, "3.0.29", "release version must remain unchanged without explicit approval");
47
48
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
48
49
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
49
50
  }
@@ -113,34 +113,39 @@ assert.strictEqual(inChat(CH_EXT, () => relationship.currentSpeaker().name), "Ze
113
113
  assert.strictEqual(relationship.currentSpeaker(), null, "no chat context → null speaker");
114
114
  assert.strictEqual(relationship.isCurrentSpeakerGuarded(), false, "no context → not guarded");
115
115
 
116
- // ── ownerTarget resolves the owner's PRIMARY handle for escalation buttons ────
117
- const target = relationship.ownerTarget();
118
- assert.ok(target, "ownerTarget resolves an owner handle");
119
- assert.strictEqual(target.adapter, "telegram", "ownerTarget adapter");
120
- assert.strictEqual(target.channelId, CH_OWNER_ALT, "ownerTarget picks the primary channel, not the first");
121
-
122
- // ── split-brain fix: an owner channel unified via /link (identities.json) but
123
- // NEVER mirrored onto the people record must STILL resolve to the owner. This
124
- // is the production bug a Kazee handle /link'd to the owner's canonical id was
125
- // invisible to people.findByHandle, so the guardrail falsely treated the owner
126
- // as an external speaker. Ownership now flows through canonical identity. ──
127
- const identity = require("./core/identity");
128
- const ownerCanon = identity.ownerCanonical();
129
- assert.ok(ownerCanon, "owner has a canonical id");
130
- identity.setIdentityMapping("kazee", "owner-linked-kazee", ownerCanon);
131
- assert.strictEqual(people.findByHandle("kazee", "owner-linked-kazee"), null,
132
- "precondition: the /link'd kazee channel is absent from the people record");
133
- const linkedSp = relationship.speakerFor("kazee", "owner-linked-kazee");
134
- assert.strictEqual(linkedSp.isOwner, true, "owner-canonical channel → owner even without a people handle");
135
- assert.strictEqual(linkedSp.relationship, "owner", "owner-canonical channel relationship");
136
- assert.strictEqual(linkedSp.mandate, "", "owner never carries a mandate");
137
- assert.strictEqual(relationship.guardActive(linkedSp), false, "owner-canonical channel is never guarded");
138
-
139
- // ── and the fix must NOT widen: an unmapped channel on the same transport that
140
- // does not resolve to the owner canonical stays external + guarded. ──
141
- const strayKazee = relationship.speakerFor("kazee", "some-stranger-999");
142
- assert.strictEqual(strayKazee.isOwner, false, "unmapped kazee channel is not owner");
143
- assert.strictEqual(strayKazee.relationship, "external", "unmapped kazee channel external");
144
- assert.strictEqual(relationship.guardActive(strayKazee), true, "unmapped kazee channel stays guarded");
145
-
146
- console.log("relationship OK");
116
+ // ownerTarget is async (its env fallback may resolve a Kazee DM over the
117
+ // network); the owner here has a people-record handle, so it resolves via the
118
+ // synchronous people path the fallback is exercised in test-enforcer.
119
+ (async () => {
120
+ // ── ownerTarget resolves the owner's PRIMARY handle for escalation buttons ──
121
+ const target = await relationship.ownerTarget();
122
+ assert.ok(target, "ownerTarget resolves an owner handle");
123
+ assert.strictEqual(target.adapter, "telegram", "ownerTarget adapter");
124
+ assert.strictEqual(target.channelId, CH_OWNER_ALT, "ownerTarget picks the primary channel, not the first");
125
+
126
+ // ── split-brain fix: an owner channel unified via /link (identities.json) but
127
+ // NEVER mirrored onto the people record must STILL resolve to the owner. This
128
+ // is the production bug — a Kazee handle /link'd to the owner's canonical id was
129
+ // invisible to people.findByHandle, so the guardrail falsely treated the owner
130
+ // as an external speaker. Ownership now flows through canonical identity. ──
131
+ const identity = require("./core/identity");
132
+ const ownerCanon = identity.ownerCanonical();
133
+ assert.ok(ownerCanon, "owner has a canonical id");
134
+ identity.setIdentityMapping("kazee", "owner-linked-kazee", ownerCanon);
135
+ assert.strictEqual(people.findByHandle("kazee", "owner-linked-kazee"), null,
136
+ "precondition: the /link'd kazee channel is absent from the people record");
137
+ const linkedSp = relationship.speakerFor("kazee", "owner-linked-kazee");
138
+ assert.strictEqual(linkedSp.isOwner, true, "owner-canonical channel → owner even without a people handle");
139
+ assert.strictEqual(linkedSp.relationship, "owner", "owner-canonical channel relationship");
140
+ assert.strictEqual(linkedSp.mandate, "", "owner never carries a mandate");
141
+ assert.strictEqual(relationship.guardActive(linkedSp), false, "owner-canonical channel is never guarded");
142
+
143
+ // ── and the fix must NOT widen: an unmapped channel on the same transport that
144
+ // does not resolve to the owner canonical stays external + guarded. ──
145
+ const strayKazee = relationship.speakerFor("kazee", "some-stranger-999");
146
+ assert.strictEqual(strayKazee.isOwner, false, "unmapped kazee channel is not owner");
147
+ assert.strictEqual(strayKazee.relationship, "external", "unmapped kazee channel → external");
148
+ assert.strictEqual(relationship.guardActive(strayKazee), true, "unmapped kazee channel stays guarded");
149
+
150
+ console.log("relationship OK");
151
+ })().catch((e) => { console.error(e); process.exit(1); });