@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 +13 -0
- package/core/enforcer.js +1 -1
- package/core/handlers.js +9 -1
- package/core/loopback.js +1 -1
- package/core/relationship.js +54 -18
- package/package.json +1 -1
- package/test-enforcer.js +33 -1
- package/test-provider-language.js +13 -12
- package/test-relationship.js +36 -31
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(
|
|
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;
|
package/core/relationship.js
CHANGED
|
@@ -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
|
|
113
|
-
// owner
|
|
114
|
-
//
|
|
115
|
-
|
|
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 (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
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: {
|
|
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.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// "
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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 auth — so 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
|
}
|
package/test-relationship.js
CHANGED
|
@@ -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
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
identity.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
assert.strictEqual(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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); });
|