@inetafrica/open-claudia 3.4.23 → 3.5.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.
@@ -176,6 +176,46 @@ const lastSend = () => sends[sends.length - 1]?.text || "";
176
176
  assert.ok(rows.includes(`grl:${ruled.id}:channel`), "channel scope offered");
177
177
  assert.ok(rows.includes(`grl:${ruled.id}:global`), "global scope offered");
178
178
 
179
+ // ── grn: standing grants from a card ("Always — here / everywhere") ──
180
+ const grants = require("./core/grants");
181
+ const pair = { speakerId: "user-lucy", room: "kazee:ext-channel" };
182
+
183
+ // Destructive is never standing: the tap is refused and nothing is written.
184
+ const destructive = approvals.create({ ...base, ...pair, kind: "action", tier: "destructive", command: "open-claudia tool run infra delete-vm vm-1" });
185
+ await handleAction(envelopeFor(`grn:${destructive.id}:all`));
186
+ assert.ok(/^Destructive actions always ask; no standing permission/.test(lastSend()), "owner is told destructive always asks");
187
+ await handleAction(envelopeFor(`grn:${destructive.id}:room`));
188
+ assert.ok(/^Destructive actions always ask/.test(lastSend()));
189
+ assert.strictEqual(grants.list().length, 0, "no grant written from a destructive card");
190
+
191
+ // Anonymous or unverifiable principals are refused at the button.
192
+ const anon = approvals.create({
193
+ ...base, adapter: "telegram", targetAdapter: "telegram", channelId: "-100555", targetChannel: "-100555",
194
+ kind: "action", tier: "write", command: "open-claudia tool run kticket create",
195
+ speakerId: "1087968824", room: "telegram:-100555",
196
+ });
197
+ await handleAction(envelopeFor(`grn:${anon.id}:room`));
198
+ assert.ok(/^No standing permission for an anonymous group admin.*Use Approve once/.test(lastSend()), "anonymous admin refused at the button");
199
+ const roomAsSender = approvals.create({ ...base, kind: "reply", tier: "reply", payloadText: "Hi.", speakerId: "ext-channel", room: "kazee:ext-channel" });
200
+ await handleAction(envelopeFor(`grn:${roomAsSender.id}:all`));
201
+ assert.ok(/^No standing permission for the room itself/.test(lastSend()), "a sender that is really the room is refused");
202
+ assert.strictEqual(grants.list().length, 0, "no grant written for an unverifiable principal");
203
+
204
+ // Only an owner can grant.
205
+ const write = approvals.create({ ...base, ...pair, kind: "action", tier: "write", command: "open-claudia tool run kticket create" });
206
+ await handleAction(envelopeFor(`grn:${write.id}:room`, { channelId: "intruder-chat" }));
207
+ assert.ok(/Owner only/.test(lastSend()));
208
+ assert.strictEqual(grants.list().length, 0);
209
+
210
+ // A write card from one identifiable person grants `act` for that pair.
211
+ await handleAction(envelopeFor(`grn:${write.id}:room`));
212
+ assert.ok(/Granted Lucy `act`/.test(lastSend()), "write card grants act");
213
+ const issued = grants.list();
214
+ assert.strictEqual(issued.length, 1);
215
+ assert.strictEqual(issued[0].capability, "act");
216
+ assert.strictEqual(issued[0].principal.channelId, "user-lucy");
217
+ assert.strictEqual(issued[0].room, "kazee:ext-channel");
218
+
179
219
  // ── expired card: nothing to decide ──
180
220
  await handleAction(envelopeFor("apr:apr_0000_dead:ok"));
181
221
  assert.ok(/expired|cleaned up/.test(lastEdit()), "stale card gets a clear ack");
@@ -289,6 +289,98 @@ async function codexRestrictionProbe() {
289
289
  assert.deepStrictEqual(claudeInvocation.args.slice(claudeInvocation.args.indexOf("--permission-mode"), claudeInvocation.args.indexOf("--permission-mode") + 2), ["--permission-mode", "plan"]);
290
290
  }
291
291
 
292
+ // 21 Sep (task 76ecr7): a watch passed while a long turn ran, the turn ended in
293
+ // a compaction, and the alarm looked lost. The queued delivery waits out the
294
+ // turn and the compaction, then runs once at the next idle moment on the
295
+ // compacted session; after a later session switch it still runs, on its own
296
+ // branch, without taking over the live session.
297
+ async function deferredWatchAcrossCompactionProbe() {
298
+ const { createSchedulerService } = require("./core/scheduler");
299
+ for (const switchAfter of [false, true]) {
300
+ resetFixture("claude");
301
+ const records = new Map([["watch-long-turn", {
302
+ id: "watch-long-turn",
303
+ kind: "cron",
304
+ schedule: "* * * * *",
305
+ check: "fixture-check",
306
+ repeat: false,
307
+ expiresAt: Date.now() + 60 * 60 * 1000,
308
+ adapter: "telegram-fixture",
309
+ adapterType: "telegram",
310
+ channelId: "fixture",
311
+ canonicalUserId: state.userId,
312
+ provider: "claude",
313
+ providerSettings: {},
314
+ project: "alpha",
315
+ projectDir: alphaDir,
316
+ sessionId: "old-claude",
317
+ prompt: "the SDK tag pipeline finished",
318
+ label: "SDK pipeline",
319
+ occurrenceId: "occ-long-turn",
320
+ status: "pending",
321
+ attemptCount: 0,
322
+ maxAttempts: 3,
323
+ disabled: false,
324
+ }]]);
325
+ const jobsStore = {
326
+ get: (id) => (records.has(id) ? structuredClone(records.get(id)) : null),
327
+ update(id, patch) {
328
+ if (!records.has(id)) return null;
329
+ records.set(id, { ...records.get(id), ...structuredClone(patch) });
330
+ return structuredClone(records.get(id));
331
+ },
332
+ remove(id) { const value = records.get(id) || null; records.delete(id); return value; },
333
+ listAll: () => [...records.values()].map((job) => structuredClone(job)),
334
+ };
335
+ const timers = [];
336
+ const runs = [];
337
+ const scheduler = createSchedulerService({
338
+ jobsStore,
339
+ adapters: [{ id: "telegram-fixture", type: "telegram" }],
340
+ workspace,
341
+ getUserState: () => state,
342
+ runInChat: async (_chat, fn) => fn(),
343
+ send: async () => ({ ok: true }),
344
+ setTimer: (fn, delay) => { timers.push({ fn, delay }); return timers.length; },
345
+ clearTimer: () => {},
346
+ scheduleCron: () => ({ stop() {} }),
347
+ providerStatus: () => ({ availability: { state: "available" }, auth: { state: "authenticated" } }),
348
+ fallbackProviders: [],
349
+ runWatchCheck: async () => ({ code: 0 }),
350
+ admitRunContext: (_prompt, cwd, _replyTo, opts) => ({ cwd, ...opts }),
351
+ runAgent: async (_prompt, _cwd, _replyTo, opts) => {
352
+ runs.push(opts);
353
+ return { ok: true, status: "succeeded", persistence: { transcript: { ok: true } }, delivery: { ok: true } };
354
+ },
355
+ });
356
+
357
+ state.runningProcess = { pid: 4242 };
358
+ assert.strictEqual((await scheduler.fireJob("watch-long-turn")).deferred, true, "a watch that passes mid-turn is queued");
359
+ for (let i = 0; i < 3; i++) await timers.at(-1).fn();
360
+ state.runningProcess = null;
361
+ let calls = 0;
362
+ const compaction = serviceWith(async () => {
363
+ calls += 1;
364
+ if (calls === 1) {
365
+ assert.strictEqual(state.isCompacting, true);
366
+ assert.strictEqual((await timers.at(-1).fn()).deferred, true, "the delivery waits out the compaction");
367
+ return result({ sessionId: "old-claude" });
368
+ }
369
+ return result({ sessionId: "compacted-claude" });
370
+ });
371
+ assert.strictEqual((await compaction.compact(alphaDir, { operationId: `compact-watch-${switchAfter}` })).compacted, true);
372
+ assert.strictEqual(runs.length, 0);
373
+ assert.ok(records.get("watch-long-turn"), "the queued delivery survives the compaction");
374
+ if (switchAfter) stateApi.setProviderSession(state, "claude", "alpha", "fresh-claude-after-new");
375
+
376
+ assert.strictEqual((await timers.at(-1).fn()).ok, true, "the next idle moment delivers it");
377
+ assert.strictEqual(runs.length, 1, "exactly once");
378
+ assert.strictEqual(runs[0].resumeSessionId, "compacted-claude", "it resumes the compacted session, not the pre-compaction one");
379
+ assert.strictEqual(runs[0].attachSession, !switchAfter, "it never takes over a session the user switched to");
380
+ assert.strictEqual(records.has("watch-long-turn"), false, "a delivered one-shot watch is consumed, not left listed");
381
+ }
382
+ }
383
+
292
384
  function duplicateCommitProbe() {
293
385
  resetFixture("claude");
294
386
  const transaction = {
@@ -364,6 +456,7 @@ function restartRecoveryProbe() {
364
456
  await immutableSuccessProbe();
365
457
  await failureProbe();
366
458
  await codexRestrictionProbe();
459
+ await deferredWatchAcrossCompactionProbe();
367
460
  duplicateCommitProbe();
368
461
  restartRecoveryProbe();
369
462
  console.log("provider compaction OK");
@@ -500,7 +500,21 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
500
500
  // stopped run unwinds and its flag names that run, so it no longer cancels the
501
501
  // next messages; "Queued." names what it waits on; a watch alarm stuck 15 min
502
502
  // behind a busy room posts one line.
503
- assert.strictEqual(pkg.version, "3.4.23", "release version must remain unchanged without explicit approval");
503
+ // 3.4.24 approved by Sumeet 2026-09-24 20:30 EAT ("If there's fixes needed for open
504
+ // Claudia proceed."): a repeating watch keeps polling while its delivery waits or
505
+ // retries, and new hits fold into it; 3 check timeouts in a row alarm once; far
506
+ // wakeups sleep in chunks instead of firing at once; a failed jobs-store write
507
+ // keeps the retry armed; listings say where a fired job stands; loopback says
508
+ // which step failed; the pack-review release gate no longer flakes on 30 s.
509
+ // 3.5.0 approved by Sumeet 2026-09-25 14:43 EAT ("yes work through it end to end",
510
+ // the permission redesign, phase 0 security fixes first): the bot decides who a
511
+ // process acts for (run attestation, whoami); staff tool writes are decided and
512
+ // escalated in the bot; undeclared verbs refused; approvals carry their tier;
513
+ // tool/note/keyring/sub-agent changes owner-only; staff follow-ups run at the
514
+ // setter's level; notes/tasks owner-only and search stays in the room; reads
515
+ // screened and the hook fails closed on staff turns; no standing destructive
516
+ // or anonymous grants; canary release tags. Shipped first as canary-v3.5.0.
517
+ assert.strictEqual(pkg.version, "3.5.0", "release version must remain unchanged without explicit approval");
504
518
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
505
519
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
506
520
  }
@@ -404,21 +404,43 @@ async function lifecycleProbe() {
404
404
  return { serialized: true, staleProtected: true, failureRecorded: true, shutdownDrained: true };
405
405
  }
406
406
 
407
+ // A loaded CI runner has taken over 30 s for one probe child; the kill then
408
+ // read as "failed (null)" and failed the release gate (v3.4.19, job 43703).
409
+ const CHILD_TIMEOUT_MS = 120000;
410
+
411
+ // Why a child did not exit 0: killed by the timeout, killed by a signal, never
412
+ // started, or a real non-zero exit.
413
+ function childFailure(child, timeoutMs = CHILD_TIMEOUT_MS) {
414
+ if (child.error?.code === "ETIMEDOUT") return `timed out after ${timeoutMs / 1000} s (killed with ${child.signal || "SIGTERM"})`;
415
+ if (child.signal) return `was killed by ${child.signal}`;
416
+ if (child.error) return `could not start (${child.error.code || child.error.message})`;
417
+ return `failed (exit ${child.status})`;
418
+ }
419
+
407
420
  function runChild(args) {
408
421
  const child = spawnSync(process.execPath, [__filename, ...args], {
409
422
  cwd: __dirname,
410
423
  env: { ...process.env },
411
424
  encoding: "utf8",
412
- timeout: 30000,
425
+ timeout: CHILD_TIMEOUT_MS,
413
426
  });
414
427
  if (child.status !== 0) {
415
- throw new Error(`child ${args.join(" ")} failed (${child.status})\n${child.stdout}\n${child.stderr}`);
428
+ throw new Error(`child ${args.join(" ")} ${childFailure(child)}\n${child.stdout}\n${child.stderr}`);
416
429
  }
417
430
  const line = child.stdout.split("\n").find((entry) => entry.startsWith(RESULT_PREFIX));
418
431
  if (!line) throw new Error(`child ${args.join(" ")} returned no result\n${child.stdout}\n${child.stderr}`);
419
432
  return JSON.parse(line.slice(RESULT_PREFIX.length));
420
433
  }
421
434
 
435
+ function childFailureProbe() {
436
+ const hung = spawnSync(process.execPath, ["-e", "setTimeout(() => {}, 10000)"], { encoding: "utf8", timeout: 200 });
437
+ assert.strictEqual(childFailure(hung, 200), "timed out after 0.2 s (killed with SIGTERM)", "a timeout reads as a timeout");
438
+ const failing = spawnSync(process.execPath, ["-e", "process.exit(3)"], { encoding: "utf8", timeout: 10000 });
439
+ assert.strictEqual(childFailure(failing), "failed (exit 3)", "a failing probe keeps its exit code");
440
+ const killed = spawnSync(process.execPath, ["-e", "process.kill(process.pid, 'SIGKILL')"], { encoding: "utf8", timeout: 10000 });
441
+ assert.strictEqual(childFailure(killed), "was killed by SIGKILL");
442
+ }
443
+
422
444
  async function main() {
423
445
  if (process.argv[2] === "--provider-probe") {
424
446
  const result = await providerProbe(process.argv[3]);
@@ -431,6 +453,7 @@ async function main() {
431
453
  return;
432
454
  }
433
455
 
456
+ childFailureProbe();
434
457
  const claude = runChild(["--provider-probe", "claude"]);
435
458
  const codex = runChild(["--provider-probe", "codex"]);
436
459
  assert.deepStrictEqual(codex.mutations, claude.mutations, "identical fixture decisions produce identical memory mutations under Claude and Codex");
@@ -21,7 +21,9 @@ const SUPPORT_SUITES = Object.freeze({
21
21
  scheduler: ["test-provider-scheduler.js", "provider scheduler OK"],
22
22
  sideChat: ["test-provider-side-chat.js", "provider side chat OK"],
23
23
  recall: ["test-provider-recall.js", "provider recall OK"],
24
- review: ["test-provider-pack-review.js", "provider pack review OK"],
24
+ // Three probe children, each allowed 120 s under a loaded runner; the suite
25
+ // cap must not undercut them (a 90 s cap would move the v3.4.19 flake here).
26
+ review: ["test-provider-pack-review.js", "provider pack review OK", 8 * 60 * 1000],
25
27
  mutationQueue: ["test-memory-mutation-queue.js", "memory mutation queue OK"],
26
28
  dream: ["test-provider-dream.js", "provider dream OK"],
27
29
  subagent: ["test-provider-subagent.js", "provider subagent OK"],
@@ -457,7 +459,7 @@ function runProviderChild(kind) {
457
459
  function runSupportSuites(provider) {
458
460
  assert.ok(["claude", "codex"].includes(provider));
459
461
  const passed = new Set();
460
- for (const [key, [script, marker]] of Object.entries(SUPPORT_SUITES)) {
462
+ for (const [key, [script, marker, suiteTimeoutMs = 90000]] of Object.entries(SUPPORT_SUITES)) {
461
463
  const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-parity-suite-${key}-`));
462
464
  try {
463
465
  fs.mkdirSync(path.join(root, "home"));
@@ -466,7 +468,7 @@ function runSupportSuites(provider) {
466
468
  cwd: repoRoot,
467
469
  env: isolatedEnv(root, provider),
468
470
  encoding: "utf8",
469
- timeout: 90000,
471
+ timeout: suiteTimeoutMs,
470
472
  maxBuffer: 8 * 1024 * 1024,
471
473
  });
472
474
  assert.ifError(result.error);