@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.
- package/CHANGELOG.md +50 -0
- package/bin/agent.js +4 -0
- package/bin/cli.js +10 -1
- package/bin/entity.js +17 -15
- package/bin/ideas.js +10 -1
- package/bin/keyring.js +19 -18
- package/bin/lessons.js +10 -1
- package/bin/loopback-client.js +46 -1
- package/bin/owner-gate.js +38 -0
- package/bin/pack.js +11 -1
- package/bin/schedule.js +45 -11
- package/bin/tool.js +77 -49
- package/bin/transcript-search.js +42 -8
- package/bin/transcript-window.js +31 -2
- package/core/actions.js +15 -4
- package/core/approvals.js +28 -2
- package/core/grants.js +156 -27
- package/core/handlers.js +8 -2
- package/core/loopback.js +237 -71
- package/core/providers/claude-hook.js +5 -0
- package/core/runner.js +47 -2
- package/core/scheduler.js +307 -37
- package/core/speaker-attest.js +82 -1
- package/core/tool-guard.js +380 -11
- package/package.json +2 -2
- package/project-transcripts.js +25 -0
- package/test-approval-async.js +22 -0
- package/test-approval-cards.js +40 -0
- package/test-provider-compaction.js +93 -0
- package/test-provider-language.js +15 -1
- package/test-provider-pack-review.js +25 -2
- package/test-provider-parity-e2e.js +5 -3
- package/test-provider-scheduler.js +504 -3
- package/test-provider-tool-hooks.js +11 -0
- package/test-run-lock.js +2 -0
- package/test-tool-guard.js +12 -3
- package/test-tool-manifest.js +31 -0
|
@@ -6,6 +6,7 @@ const assert = require("assert");
|
|
|
6
6
|
const fs = require("fs");
|
|
7
7
|
const os = require("os");
|
|
8
8
|
const path = require("path");
|
|
9
|
+
const { spawnSync } = require("child_process");
|
|
9
10
|
|
|
10
11
|
const root = __dirname;
|
|
11
12
|
const fixturesRoot = path.join(root, "test-fixtures", "migrations");
|
|
@@ -373,6 +374,7 @@ function baseJob(overrides = {}) {
|
|
|
373
374
|
function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
374
375
|
const jobsStore = options.jobsStore || memoryJobsStore(seed);
|
|
375
376
|
const scheduled = [];
|
|
377
|
+
const crons = [];
|
|
376
378
|
const admissions = [];
|
|
377
379
|
const runCalls = [];
|
|
378
380
|
const sends = [];
|
|
@@ -401,12 +403,24 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
|
401
403
|
workspace: "/workspace",
|
|
402
404
|
now: options.now || (() => 1_720_000_000_500),
|
|
403
405
|
occurrenceIdFactory: () => `next-occ-${++occurrenceSequence}`,
|
|
404
|
-
setTimer(fn, delay) {
|
|
406
|
+
setTimer(fn, delay) {
|
|
407
|
+
// Node's setTimeout fires at once past this; no path may ask for more.
|
|
408
|
+
assert.ok(delay <= 2 ** 31 - 1, `timer delay ${delay} overflows setTimeout`);
|
|
409
|
+
scheduled.push({ fn, delay });
|
|
410
|
+
return { fn, delay };
|
|
411
|
+
},
|
|
405
412
|
clearTimer() {},
|
|
413
|
+
// Cron ticks are driven by hand: callback() is one tick of the schedule.
|
|
414
|
+
scheduleCron(expression, callback) {
|
|
415
|
+
const task = { expression, callback, stopped: false, stop() { task.stopped = true; } };
|
|
416
|
+
crons.push(task);
|
|
417
|
+
return task;
|
|
418
|
+
},
|
|
406
419
|
runInChat: async (_ctx, fn) => fn(),
|
|
407
420
|
getUserState: () => state,
|
|
408
421
|
send: async (text) => { sends.push(text); if (options.onSend) await options.onSend(state); return { ok: true }; },
|
|
409
422
|
admitRunContext(prompt, cwd, _replyTo, opts) {
|
|
423
|
+
if (options.admitError) throw options.admitError;
|
|
410
424
|
const context = Object.freeze({
|
|
411
425
|
runId: `scheduled-${admissions.length + 1}`,
|
|
412
426
|
provider: opts.provider,
|
|
@@ -425,6 +439,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
|
425
439
|
async runAgent(prompt, cwd, _replyTo, opts) {
|
|
426
440
|
runCalls.push({ prompt, cwd, opts });
|
|
427
441
|
const next = outcomes.shift();
|
|
442
|
+
if (next instanceof Error) throw next;
|
|
428
443
|
return next && typeof next.then === "function" ? next : next;
|
|
429
444
|
},
|
|
430
445
|
providerStatus: options.providerStatus || (() => ({ availability: { state: "available" }, auth: { state: "authenticated" } })),
|
|
@@ -434,7 +449,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
|
434
449
|
missedGraceMs: 60 * 60 * 1000,
|
|
435
450
|
...(options.runWatchCheck ? { runWatchCheck: options.runWatchCheck } : {}),
|
|
436
451
|
});
|
|
437
|
-
return { service, jobsStore, scheduled, admissions, runCalls, sends, state };
|
|
452
|
+
return { service, jobsStore, scheduled, crons, admissions, runCalls, sends, state };
|
|
438
453
|
}
|
|
439
454
|
|
|
440
455
|
async function firetimeSelectionProbe(createSchedulerService) {
|
|
@@ -670,6 +685,472 @@ async function busyWatchNoticeProbe(createSchedulerService) {
|
|
|
670
685
|
assert.strictEqual(w.sends.length, 0);
|
|
671
686
|
}
|
|
672
687
|
|
|
688
|
+
function succeededRun() {
|
|
689
|
+
return {
|
|
690
|
+
ok: true,
|
|
691
|
+
status: "succeeded",
|
|
692
|
+
persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
|
|
693
|
+
delivery: { ok: true },
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// One tick of a hand-driven cron, then let its fire or poll settle (the fakes
|
|
698
|
+
// all resolve in microtasks).
|
|
699
|
+
async function tick(task) {
|
|
700
|
+
task.callback();
|
|
701
|
+
for (let i = 0; i < 3; i++) await nextTurn();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function repeatingWatch(overrides = {}) {
|
|
705
|
+
return baseJob({
|
|
706
|
+
kind: "cron",
|
|
707
|
+
schedule: "*/15 * * * *",
|
|
708
|
+
fireAt: undefined,
|
|
709
|
+
check: "fixture-check",
|
|
710
|
+
repeat: true,
|
|
711
|
+
label: "EVENT BUS ALARM",
|
|
712
|
+
expiresAt: 1_720_000_000_000 + 30 * 24 * 60 * 60 * 1000,
|
|
713
|
+
...overrides,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function pendingDeliveryPollingProbe(createSchedulerService) {
|
|
718
|
+
// 24 Sep: the event-bus watch triggered at 04:15, a busy room deferred the
|
|
719
|
+
// delivery until 11:50, and the bus went unpolled the whole time.
|
|
720
|
+
const t0 = 1_720_000_000_000;
|
|
721
|
+
const at = (minutes) => t0 + minutes * 60_000;
|
|
722
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
723
|
+
let clock = t0;
|
|
724
|
+
let code = 0;
|
|
725
|
+
let checks = 0;
|
|
726
|
+
const h = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "bus", occurrenceId: "occ-bus" })], {
|
|
727
|
+
now: () => clock,
|
|
728
|
+
runWatchCheck: async () => { checks++; return { code, timedOut: code === 124 }; },
|
|
729
|
+
outcomes: [succeededRun()],
|
|
730
|
+
});
|
|
731
|
+
h.service.init();
|
|
732
|
+
assert.strictEqual(h.crons.length, 1);
|
|
733
|
+
const poll = h.crons[0];
|
|
734
|
+
h.state.runningProcess = { pid: 1 };
|
|
735
|
+
await tick(poll);
|
|
736
|
+
assert.strictEqual(h.jobsStore.get("bus").status, "deferred");
|
|
737
|
+
assert.strictEqual(poll.stopped, false, "a repeating watch keeps its poll schedule while the delivery waits");
|
|
738
|
+
assert.strictEqual(h.scheduled.length, 1, "the delivery waits on its own retry timer");
|
|
739
|
+
|
|
740
|
+
clock = at(15);
|
|
741
|
+
await tick(poll);
|
|
742
|
+
assert.strictEqual(checks, 2, "the watch polls while its delivery is pending");
|
|
743
|
+
let bus = h.jobsStore.get("bus");
|
|
744
|
+
assert.strictEqual(bus.watchExtraHits, 1, "a new hit folds into the pending delivery");
|
|
745
|
+
assert.strictEqual(bus.watchLastHitAt, at(15));
|
|
746
|
+
assert.strictEqual(bus.occurrenceId, "occ-bus");
|
|
747
|
+
assert.strictEqual(bus.watchTriggeredAt, t0, "the trigger time stays the first hit's");
|
|
748
|
+
assert.strictEqual(h.scheduled.length, 1, "no second delivery is queued");
|
|
749
|
+
|
|
750
|
+
clock = at(16);
|
|
751
|
+
await h.scheduled.at(-1).fn();
|
|
752
|
+
assert.deepStrictEqual(h.sends, ['⏳ Watch "EVENT BUS ALARM" triggered 16 min ago and is waiting for the current run to end. /stop ends that run so the watch can go.'],
|
|
753
|
+
"the 15-minute heads-up still goes out, counted from the first hit");
|
|
754
|
+
code = 1;
|
|
755
|
+
clock = at(30);
|
|
756
|
+
await tick(poll);
|
|
757
|
+
code = 124;
|
|
758
|
+
clock = at(40);
|
|
759
|
+
await tick(poll);
|
|
760
|
+
assert.strictEqual(h.jobsStore.get("bus").watchLastClearAt, at(30), "a timed-out poll is not a clear");
|
|
761
|
+
code = 0;
|
|
762
|
+
clock = at(45);
|
|
763
|
+
await tick(poll);
|
|
764
|
+
bus = h.jobsStore.get("bus");
|
|
765
|
+
assert.strictEqual(bus.watchExtraHits, 2);
|
|
766
|
+
assert.strictEqual(bus.watchLastClearAt, at(30));
|
|
767
|
+
assert.strictEqual(h.runCalls.length, 0);
|
|
768
|
+
|
|
769
|
+
h.state.runningProcess = null;
|
|
770
|
+
clock = at(46);
|
|
771
|
+
assert.strictEqual((await h.scheduled.at(-1).fn()).ok, true);
|
|
772
|
+
assert.strictEqual(h.runCalls.length, 1, "one delivery for the whole wait");
|
|
773
|
+
const prompt = h.runCalls[0].prompt;
|
|
774
|
+
assert.match(prompt, /^\[Watch triggered "EVENT BUS ALARM"/);
|
|
775
|
+
assert.ok(prompt.includes(`[This watch kept polling while this delivery waited (queued at ${iso(t0)}): the check passed 2 more times, the latest at ${iso(at(45))}; the condition was still true at the latest check.]`), prompt);
|
|
776
|
+
assert.strictEqual(h.sends.length, 2, "heads-up once, then the advisory");
|
|
777
|
+
bus = h.jobsStore.get("bus");
|
|
778
|
+
assert.strictEqual(bus.status, "pending");
|
|
779
|
+
assert.strictEqual(bus.watchExtraHits, 0, "the next occurrence starts its own count");
|
|
780
|
+
assert.strictEqual(bus.watchLastHitAt, null);
|
|
781
|
+
assert.strictEqual(bus.watchLastClearAt, null);
|
|
782
|
+
assert.strictEqual(h.crons.filter((task) => !task.stopped).length, 1, "one live poll schedule after the delivery");
|
|
783
|
+
|
|
784
|
+
// Third case: attempt 1 died with exit 143 after a /stop and polling never
|
|
785
|
+
// resumed. The watch keeps polling and the delivery is retried.
|
|
786
|
+
clock = t0;
|
|
787
|
+
code = 0;
|
|
788
|
+
const killed = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "bus2", occurrenceId: "occ-bus2" })], {
|
|
789
|
+
now: () => clock,
|
|
790
|
+
runWatchCheck: async () => ({ code }),
|
|
791
|
+
outcomes: [
|
|
792
|
+
{ ok: false, status: "failed", exitCode: 143, error: { code: "PROVIDER_EXIT_NONZERO", message: "Provider exited with code 143" }, delivery: { ok: true } },
|
|
793
|
+
succeededRun(),
|
|
794
|
+
],
|
|
795
|
+
});
|
|
796
|
+
killed.service.init();
|
|
797
|
+
const killedPoll = killed.crons[0];
|
|
798
|
+
await tick(killedPoll);
|
|
799
|
+
assert.strictEqual(killed.runCalls.length, 1);
|
|
800
|
+
assert.strictEqual(killed.jobsStore.get("bus2").status, "failed");
|
|
801
|
+
assert.strictEqual(killedPoll.stopped, false, "a killed delivery does not stop the polling");
|
|
802
|
+
assert.strictEqual(killed.scheduled.length, 1, "the pending delivery is retried");
|
|
803
|
+
code = 1;
|
|
804
|
+
clock = at(15);
|
|
805
|
+
await tick(killedPoll);
|
|
806
|
+
assert.strictEqual(killed.jobsStore.get("bus2").watchLastClearAt, at(15));
|
|
807
|
+
clock = at(16);
|
|
808
|
+
assert.strictEqual((await killed.scheduled.at(-1).fn()).ok, true);
|
|
809
|
+
assert.strictEqual(killed.runCalls.length, 2);
|
|
810
|
+
assert.ok(killed.runCalls[1].prompt.includes(`its latest run, at ${iso(at(15))}, did not pass, so the condition may have cleared`), killed.runCalls[1].prompt);
|
|
811
|
+
|
|
812
|
+
// The watch also polls while its delivery runs; a hit then reaches the retry
|
|
813
|
+
// of a run that did not finish.
|
|
814
|
+
clock = t0;
|
|
815
|
+
const inRun = deferred();
|
|
816
|
+
const during = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "bus3", occurrenceId: "occ-bus3" })], {
|
|
817
|
+
now: () => clock,
|
|
818
|
+
runWatchCheck: async () => ({ code: 0 }),
|
|
819
|
+
outcomes: [inRun.promise, succeededRun()],
|
|
820
|
+
});
|
|
821
|
+
during.service.init();
|
|
822
|
+
during.crons[0].callback();
|
|
823
|
+
await nextTurn();
|
|
824
|
+
assert.strictEqual(during.jobsStore.get("bus3").status, "running");
|
|
825
|
+
clock = at(15);
|
|
826
|
+
await tick(during.crons[0]);
|
|
827
|
+
assert.strictEqual(during.jobsStore.get("bus3").watchExtraHits, 1, "the watch polls while its delivery runs");
|
|
828
|
+
inRun.resolve({ ok: false, status: "cancelled", error: { code: "RUN_CANCELLED", message: "stopped" }, delivery: { ok: true } });
|
|
829
|
+
for (let i = 0; i < 3; i++) await nextTurn();
|
|
830
|
+
assert.strictEqual(during.jobsStore.get("bus3").status, "failed");
|
|
831
|
+
clock = at(16);
|
|
832
|
+
assert.strictEqual((await during.scheduled.at(-1).fn()).ok, true);
|
|
833
|
+
assert.ok(during.runCalls[1].prompt.includes(`the check passed 1 more time, the latest at ${iso(at(15))}`), during.runCalls[1].prompt);
|
|
834
|
+
|
|
835
|
+
// After a restart, a watch whose delivery is still queued polls again too.
|
|
836
|
+
clock = t0;
|
|
837
|
+
const bootStore = memoryJobsStore([repeatingWatch({
|
|
838
|
+
id: "bus4", occurrenceId: "occ-bus4", status: "deferred", nextAttemptAt: t0 + 30_000,
|
|
839
|
+
watchTriggeredOccurrenceId: "occ-bus4", watchTriggeredAt: t0,
|
|
840
|
+
})]);
|
|
841
|
+
const rebooted = schedulerHarness(createSchedulerService, [], { jobsStore: bootStore, now: () => clock, runWatchCheck: async () => ({ code: 0 }) });
|
|
842
|
+
rebooted.service.init();
|
|
843
|
+
assert.strictEqual(rebooted.crons.length, 1, "boot re-arms the poll schedule of a watch whose delivery is queued");
|
|
844
|
+
assert.deepStrictEqual(rebooted.scheduled.map((timer) => timer.delay), [30_000], "and its delivery retry");
|
|
845
|
+
clock = at(15);
|
|
846
|
+
await tick(rebooted.crons[0]);
|
|
847
|
+
assert.strictEqual(bootStore.get("bus4").watchExtraHits, 1);
|
|
848
|
+
|
|
849
|
+
// A retry with no latched trigger (state an older version could leave) is
|
|
850
|
+
// not a pending delivery: its tick polls nothing, the retry runs the check.
|
|
851
|
+
let legacyChecks = 0;
|
|
852
|
+
const legacy = schedulerHarness(createSchedulerService, [], {
|
|
853
|
+
jobsStore: memoryJobsStore([repeatingWatch({ id: "legacy", occurrenceId: "occ-legacy", status: "failed", nextAttemptAt: t0 + 30_000 })]),
|
|
854
|
+
now: () => clock,
|
|
855
|
+
runWatchCheck: async () => { legacyChecks++; return { code: 0 }; },
|
|
856
|
+
});
|
|
857
|
+
legacy.service.init();
|
|
858
|
+
await tick(legacy.crons[0]);
|
|
859
|
+
assert.strictEqual(legacyChecks, 0, "only a latched trigger has a delivery to fold into");
|
|
860
|
+
|
|
861
|
+
// A check that outlives the delivery must not leak into the next occurrence.
|
|
862
|
+
clock = t0;
|
|
863
|
+
let release = null;
|
|
864
|
+
const race = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "race", occurrenceId: "occ-race" })], {
|
|
865
|
+
now: () => clock,
|
|
866
|
+
runWatchCheck: () => (release === null ? Promise.resolve({ code: 0 }) : new Promise((resolve) => { release = resolve; })),
|
|
867
|
+
outcomes: [succeededRun()],
|
|
868
|
+
});
|
|
869
|
+
race.service.init();
|
|
870
|
+
race.state.runningProcess = { pid: 1 };
|
|
871
|
+
await tick(race.crons[0]);
|
|
872
|
+
release = () => {};
|
|
873
|
+
race.crons[0].callback();
|
|
874
|
+
await nextTurn();
|
|
875
|
+
race.state.runningProcess = null;
|
|
876
|
+
assert.strictEqual((await race.scheduled.at(-1).fn()).ok, true);
|
|
877
|
+
release({ code: 0 });
|
|
878
|
+
for (let i = 0; i < 3; i++) await nextTurn();
|
|
879
|
+
assert.strictEqual(race.jobsStore.get("race").occurrenceId, "next-occ-1");
|
|
880
|
+
assert.strictEqual(race.jobsStore.get("race").watchExtraHits, 0, "a late check result belongs to no occurrence");
|
|
881
|
+
|
|
882
|
+
// One-shot watches keep today's semantics: the delivery waits alone.
|
|
883
|
+
let onceChecks = 0;
|
|
884
|
+
const once = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "once", repeat: false, occurrenceId: "occ-once" })], {
|
|
885
|
+
now: () => clock,
|
|
886
|
+
runWatchCheck: async () => { onceChecks++; return { code: 0 }; },
|
|
887
|
+
});
|
|
888
|
+
once.service.init();
|
|
889
|
+
once.state.runningProcess = { pid: 1 };
|
|
890
|
+
await tick(once.crons[0]);
|
|
891
|
+
assert.strictEqual(once.crons[0].stopped, true, "a one-shot watch parks on its retry timer alone");
|
|
892
|
+
await tick(once.crons[0]);
|
|
893
|
+
assert.strictEqual(onceChecks, 1, "and is not polled while its delivery waits");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function checkTimeoutAlarmProbe(createSchedulerService) {
|
|
897
|
+
// 24 Sep: the Phone health check timed out at 09:30 and 09:45 and was only
|
|
898
|
+
// logged idle. Three timeouts in a row alarm once; the watch keeps polling.
|
|
899
|
+
let clock = 1_720_000_000_000;
|
|
900
|
+
let result = { code: 124, timedOut: true };
|
|
901
|
+
const h = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "phone", label: "Phone health", occurrenceId: "occ-phone" })], {
|
|
902
|
+
now: () => clock,
|
|
903
|
+
runWatchCheck: async () => result,
|
|
904
|
+
outcomes: [succeededRun(), succeededRun(), succeededRun()],
|
|
905
|
+
});
|
|
906
|
+
const poll = () => { clock += 15 * 60_000; return h.service.fireJob("phone"); };
|
|
907
|
+
assert.strictEqual((await poll()).status, "watch-idle");
|
|
908
|
+
assert.strictEqual((await poll()).status, "watch-idle");
|
|
909
|
+
assert.strictEqual(h.jobsStore.get("phone").watchTimeoutStreak, 2);
|
|
910
|
+
result = { code: 1, timedOut: false };
|
|
911
|
+
await poll();
|
|
912
|
+
assert.strictEqual(h.jobsStore.get("phone").watchTimeoutStreak, 0, "a completed check resets the count");
|
|
913
|
+
result = { code: 124, timedOut: true };
|
|
914
|
+
await poll();
|
|
915
|
+
await poll();
|
|
916
|
+
assert.strictEqual(h.runCalls.length, 0, "two timeouts in a row stay quiet");
|
|
917
|
+
assert.strictEqual((await poll()).ok, true);
|
|
918
|
+
assert.strictEqual(h.runCalls.length, 1, "the third timeout in a row wakes the agent");
|
|
919
|
+
assert.match(h.runCalls[0].prompt, /^\[Watch check timing out "Phone health"/);
|
|
920
|
+
assert.match(h.runCalls[0].prompt, /timed out 3 times in a row \(each run is capped at 30 s\)/);
|
|
921
|
+
assert.ok(h.sends.includes('⚠️ Watch "Phone health": its check timed out 3 times in a row'), h.sends.join("\n"));
|
|
922
|
+
const log = fs.readFileSync(process.env.OPEN_CLAUDIA_WATCH_LOG_FILE, "utf8");
|
|
923
|
+
assert.match(log, /label="Phone health" idle \(check timed out, 2 in a row\)/);
|
|
924
|
+
assert.match(log, /label="Phone health" check timed out 3 times in a row — delivery queued/);
|
|
925
|
+
assert.strictEqual(h.jobsStore.get("phone").status, "pending", "the watch keeps polling");
|
|
926
|
+
assert.strictEqual(h.jobsStore.get("phone").watchCheckTimingOut, false);
|
|
927
|
+
for (let i = 0; i < 5; i++) await poll();
|
|
928
|
+
assert.strictEqual(h.runCalls.length, 1, "a check that stays hung alarms once, not on every poll");
|
|
929
|
+
result = { code: 1, timedOut: false };
|
|
930
|
+
await poll();
|
|
931
|
+
result = { code: 124, timedOut: true };
|
|
932
|
+
for (let i = 0; i < 3; i++) await poll();
|
|
933
|
+
assert.strictEqual(h.runCalls.length, 2, "after a completed check a new run of timeouts alarms again");
|
|
934
|
+
result = { code: 0, timedOut: false };
|
|
935
|
+
await poll();
|
|
936
|
+
assert.strictEqual(h.runCalls.length, 3);
|
|
937
|
+
assert.match(h.runCalls[2].prompt, /^\[Watch triggered "Phone health"/);
|
|
938
|
+
assert.strictEqual(h.jobsStore.get("phone").watchTimeoutStreak, 0, "a passing check resets the count too");
|
|
939
|
+
|
|
940
|
+
// A timeout alarm stuck behind a busy room gets the same one heads-up, and
|
|
941
|
+
// a check that completes while it waits resets the count.
|
|
942
|
+
let hungResult = { code: 124, timedOut: true };
|
|
943
|
+
const hung = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "hung", label: "Phone health", occurrenceId: "occ-hung", watchTimeoutStreak: 2 })], {
|
|
944
|
+
now: () => clock,
|
|
945
|
+
runWatchCheck: async () => hungResult,
|
|
946
|
+
});
|
|
947
|
+
hung.state.runningProcess = { pid: 1 };
|
|
948
|
+
const raisedAt = clock;
|
|
949
|
+
await hung.service.fireJob("hung");
|
|
950
|
+
clock = raisedAt + 16 * 60_000;
|
|
951
|
+
await hung.scheduled.at(-1).fn();
|
|
952
|
+
assert.deepStrictEqual(hung.sends, ['⏳ Watch "Phone health" raised a check-timeout alarm 16 min ago and is waiting for the current run to end. /stop ends that run so the watch can go.']);
|
|
953
|
+
hung.service.init();
|
|
954
|
+
hungResult = { code: 1, timedOut: false };
|
|
955
|
+
await tick(hung.crons.at(-1));
|
|
956
|
+
assert.strictEqual(hung.jobsStore.get("hung").watchTimeoutStreak, 0, "a completed poll while the alarm waits resets the count");
|
|
957
|
+
|
|
958
|
+
// A one-shot watch is not used up by a timeout alarm; a real hit still ends it.
|
|
959
|
+
let onceResult = { code: 124, timedOut: true };
|
|
960
|
+
const once = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "once", repeat: false, label: "deploy done", occurrenceId: "occ-once" })], {
|
|
961
|
+
now: () => clock,
|
|
962
|
+
runWatchCheck: async () => onceResult,
|
|
963
|
+
outcomes: [succeededRun(), succeededRun()],
|
|
964
|
+
});
|
|
965
|
+
for (let i = 0; i < 3; i++) await once.service.fireJob("once");
|
|
966
|
+
assert.strictEqual(once.runCalls.length, 1);
|
|
967
|
+
assert.strictEqual(once.jobsStore.get("once")?.status, "pending", "a timeout alarm does not use up a one-shot watch");
|
|
968
|
+
onceResult = { code: 0, timedOut: false };
|
|
969
|
+
await once.service.fireJob("once");
|
|
970
|
+
assert.strictEqual(once.runCalls.length, 2);
|
|
971
|
+
assert.match(once.runCalls[1].prompt, /^\[Watch triggered "deploy done"/);
|
|
972
|
+
assert.strictEqual(once.jobsStore.get("once"), null, "the real trigger still ends a one-shot watch");
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function farWakeupProbe(createSchedulerService, MAX_TIMER_DELAY_MS) {
|
|
976
|
+
// 17 Sep: a reminder set for 2028 fired 61 s after it was set. setTimeout
|
|
977
|
+
// fires at once past 2^31-1 ms, so a far wakeup sleeps in chunks.
|
|
978
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
979
|
+
for (const [label, horizon] of [["thirty-day", 30 * DAY], ["two-year", 730 * DAY]]) {
|
|
980
|
+
const t0 = 1_720_000_000_000;
|
|
981
|
+
let clock = t0;
|
|
982
|
+
const h = schedulerHarness(createSchedulerService, [baseJob({ id: label, fireAt: t0 + horizon, occurrenceId: `occ-${label}` })], {
|
|
983
|
+
now: () => clock,
|
|
984
|
+
outcomes: [succeededRun()],
|
|
985
|
+
});
|
|
986
|
+
h.service.init();
|
|
987
|
+
let chunks = 0;
|
|
988
|
+
for (let timer = h.scheduled.at(-1); clock + timer.delay < t0 + horizon; timer = h.scheduled.at(-1)) {
|
|
989
|
+
clock += timer.delay;
|
|
990
|
+
timer.fn();
|
|
991
|
+
chunks += 1;
|
|
992
|
+
assert.strictEqual(h.runCalls.length, 0, `${label}: a chunk never fires the wakeup early`);
|
|
993
|
+
assert.strictEqual(h.jobsStore.get(label).status, "pending");
|
|
994
|
+
}
|
|
995
|
+
assert.strictEqual(chunks, Math.ceil(horizon / MAX_TIMER_DELAY_MS) - 1, `${label}: slept in whole chunks`);
|
|
996
|
+
const final = h.scheduled.at(-1);
|
|
997
|
+
clock += final.delay;
|
|
998
|
+
assert.strictEqual(clock, t0 + horizon, `${label}: the last timer lands on the fire time`);
|
|
999
|
+
assert.strictEqual((await final.fn()).ok, true);
|
|
1000
|
+
assert.strictEqual(h.runCalls.length, 1, `${label}: fires once when due`);
|
|
1001
|
+
assert.strictEqual(h.jobsStore.get(label), null, `${label}: consumed after it fires`);
|
|
1002
|
+
const armed = h.scheduled.length;
|
|
1003
|
+
h.service.init();
|
|
1004
|
+
await final.fn();
|
|
1005
|
+
assert.strictEqual(h.scheduled.length, armed, `${label}: nothing re-arms a consumed wakeup`);
|
|
1006
|
+
assert.strictEqual(h.runCalls.length, 1, `${label}: never fires twice`);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const due = schedulerHarness(createSchedulerService, [baseJob({ id: "due", fireAt: 1_720_000_000_500 })], { outcomes: [succeededRun(), succeededRun()] });
|
|
1010
|
+
due.service.init();
|
|
1011
|
+
assert.deepStrictEqual(due.scheduled.map((timer) => timer.delay), [0]);
|
|
1012
|
+
await due.scheduled[0].fn();
|
|
1013
|
+
await due.scheduled[0].fn();
|
|
1014
|
+
assert.strictEqual(due.runCalls.length, 1, "a due wakeup fires once");
|
|
1015
|
+
assert.strictEqual(due.jobsStore.get("due"), null);
|
|
1016
|
+
|
|
1017
|
+
// With Node's own setTimeout: a 30-day wakeup must not fire now.
|
|
1018
|
+
const runs = [];
|
|
1019
|
+
const real = createSchedulerService({
|
|
1020
|
+
jobsStore: memoryJobsStore([baseJob({ id: "real-far", fireAt: Date.now() + 30 * DAY })]),
|
|
1021
|
+
adapters: [{ id: "telegram-fixture", type: "telegram" }],
|
|
1022
|
+
workspace: "/workspace",
|
|
1023
|
+
runInChat: async (_ctx, fn) => fn(),
|
|
1024
|
+
getUserState: () => ({ userId: "fixture-user", settings: { backend: "claude" }, activeSessions: {}, completedCompactions: {} }),
|
|
1025
|
+
send: async () => ({ ok: true }),
|
|
1026
|
+
admitRunContext: (_prompt, cwd) => ({ cwd }),
|
|
1027
|
+
runAgent: async () => { runs.push("fired"); return succeededRun(); },
|
|
1028
|
+
providerStatus: () => ({ availability: { state: "available" }, auth: { state: "authenticated" } }),
|
|
1029
|
+
fallbackProviders: [],
|
|
1030
|
+
});
|
|
1031
|
+
real.init();
|
|
1032
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1033
|
+
real.stopAll();
|
|
1034
|
+
assert.deepStrictEqual(runs, [], "a real 30-day timer does not fire at once");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function wakeupHorizonCliProbe() {
|
|
1038
|
+
// The CLI refuses a <when> more than 10 years out before it calls the bot.
|
|
1039
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "oc-wakeup-horizon-"));
|
|
1040
|
+
const env = { PATH: process.env.PATH, HOME: temp, OPEN_CLAUDIA_CONFIG_DIR: temp };
|
|
1041
|
+
const run = (when) => spawnSync(process.execPath, ["-e", `require("./bin/schedule").runScheduleWakeup([${JSON.stringify(when)}, "fixture reminder"])`], {
|
|
1042
|
+
cwd: root, env, encoding: "utf8", timeout: 30000,
|
|
1043
|
+
});
|
|
1044
|
+
try {
|
|
1045
|
+
const far = run("4000d");
|
|
1046
|
+
assert.strictEqual(far.status, 2, far.stderr);
|
|
1047
|
+
assert.match(far.stderr, /^<when> "4000d" is \d{4}-\d\d-\d\dT[\d:.]+Z, more than 10 years out\. Check the date; for a far reminder use a yearly cron-add\.\n$/);
|
|
1048
|
+
const absurd = run("99999999999999999999d");
|
|
1049
|
+
assert.strictEqual(absurd.status, 2, absurd.stderr);
|
|
1050
|
+
assert.match(absurd.stderr, /is past any valid date, more than 10 years out/);
|
|
1051
|
+
const near = run("3600d");
|
|
1052
|
+
assert.strictEqual(near.status, 2);
|
|
1053
|
+
assert.match(near.stderr, /No active chat context/, "a <when> inside 10 years passes the horizon check");
|
|
1054
|
+
} finally {
|
|
1055
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function listingProbe() {
|
|
1060
|
+
// A fired job must not keep reading as a future one (17 Sep: a fired 2028
|
|
1061
|
+
// reminder still listed "fires 2028-08-01"; 21 Sep: a triggered watch whose
|
|
1062
|
+
// delivery was queued listed "EXPIRED (fires on next poll)" and was taken
|
|
1063
|
+
// for a watch that never passed).
|
|
1064
|
+
const { fmtJob, fmtWatch } = require("./bin/schedule");
|
|
1065
|
+
const triggeredAt = Date.UTC(2026, 8, 21, 6, 46, 1);
|
|
1066
|
+
const watch = {
|
|
1067
|
+
id: "cron_x_hn854u", check: "pipeline-terminal 12933", schedule: "* * * * *", repeat: false,
|
|
1068
|
+
label: "SDK tag pipeline", occurrenceId: "occ", expiresAt: triggeredAt + 60_000,
|
|
1069
|
+
};
|
|
1070
|
+
assert.match(fmtWatch({ ...watch, status: "pending" }, triggeredAt + 120_000), /polls "\* \* \* \* \*" EXPIRED \(fires on next poll\) check:/,
|
|
1071
|
+
"an untriggered watch still shows its expiry");
|
|
1072
|
+
const queued = fmtWatch({ ...watch, status: "deferred", watchTriggeredOccurrenceId: "occ", watchTriggeredAt: triggeredAt }, triggeredAt + 120_000);
|
|
1073
|
+
assert.strictEqual(queued, 'cron_x_hn854u one-shot polls "* * * * *" TRIGGERED 2026-09-21T06:46:01.000Z, delivery waiting for a busy room check: pipeline-terminal 12933 → SDK tag pipeline');
|
|
1074
|
+
assert.match(fmtWatch({ ...watch, status: "failed", watchTriggeredOccurrenceId: "occ" }), /polls "\* \* \* \* \*" delivery failed \(error\), retrying check:/,
|
|
1075
|
+
"a latch without a trigger time still lists");
|
|
1076
|
+
const wake = { id: "wakeup_x", kind: "wakeup", label: "rotate token", fireAt: Date.UTC(2028, 7, 1) };
|
|
1077
|
+
assert.strictEqual(fmtJob({ ...wake, status: "pending" }), "wakeup_x wakeup rotate token fires 2028-08-01T00:00:00.000Z");
|
|
1078
|
+
assert.strictEqual(fmtJob({ ...wake, status: "running" }), "wakeup_x wakeup rotate token fires 2028-08-01T00:00:00.000Z [delivery running]");
|
|
1079
|
+
assert.match(fmtJob({ ...wake, status: "failed", lastError: { code: "PROVIDER_EXIT_NONZERO" } }), / \[delivery failed \(PROVIDER_EXIT_NONZERO\), retrying\]$/);
|
|
1080
|
+
assert.match(fmtJob({ ...wake, status: "dead-letter", lastError: { code: "PROVIDER_UNAUTHENTICATED" } }), / \[delivery failed \(PROVIDER_UNAUTHENTICATED\), DEAD: will not fire\]$/);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
async function strandedDeliveryProbe(createSchedulerService) {
|
|
1084
|
+
// 21 and 23 Sep: watch alarms that triggered during long turns were never
|
|
1085
|
+
// delivered. A jobs-store write that throws (the disk was full on 15 Sep)
|
|
1086
|
+
// must leave a retry armed, and the delivery goes at the next idle moment.
|
|
1087
|
+
const diskFull = () => Object.assign(new Error("ENOSPC: no space left on device, write"), { code: "ENOSPC" });
|
|
1088
|
+
const failedRun = { ok: false, status: "failed", exitCode: 1, error: { code: "PROVIDER_EXIT_NONZERO", message: "exit 1" }, delivery: { ok: true } };
|
|
1089
|
+
const cases = [
|
|
1090
|
+
["busy deferral", (patch) => patch.status === "deferred" && !!patch.busyDeferralCount, { busy: true }],
|
|
1091
|
+
["attempt start", (patch) => patch.status === "running", {}],
|
|
1092
|
+
["failed run", (patch) => patch.status === "failed", { outcomes: [failedRun] }],
|
|
1093
|
+
["thrown run", (patch) => patch.status === "failed", { outcomes: [new Error("provider crashed")] }],
|
|
1094
|
+
["admission refusal", (patch) => patch.status === "failed", { admitError: new Error("Choose Claude Code or OpenAI Codex before starting a model turn") }],
|
|
1095
|
+
];
|
|
1096
|
+
for (const [name, faulty, setup] of cases) {
|
|
1097
|
+
let clock = 1_720_000_000_000;
|
|
1098
|
+
const store = memoryJobsStore([repeatingWatch({ id: "w", repeat: false, occurrenceId: "occ-w" })]);
|
|
1099
|
+
const update = store.update.bind(store);
|
|
1100
|
+
let armed = true;
|
|
1101
|
+
let faults = 0;
|
|
1102
|
+
store.update = (id, patch) => {
|
|
1103
|
+
if (armed && faulty(patch)) { armed = false; faults++; throw diskFull(); }
|
|
1104
|
+
return update(id, patch);
|
|
1105
|
+
};
|
|
1106
|
+
const options = {
|
|
1107
|
+
jobsStore: store,
|
|
1108
|
+
now: () => clock,
|
|
1109
|
+
runWatchCheck: async () => ({ code: 0 }),
|
|
1110
|
+
outcomes: [...(setup.outcomes || []), succeededRun()],
|
|
1111
|
+
admitError: setup.admitError || null,
|
|
1112
|
+
};
|
|
1113
|
+
const h = schedulerHarness(createSchedulerService, [], options);
|
|
1114
|
+
if (setup.busy) h.state.runningProcess = { pid: 1 };
|
|
1115
|
+
const first = await h.service.fireJob("w");
|
|
1116
|
+
assert.strictEqual(faults, 1, `${name}: the write failed`);
|
|
1117
|
+
assert.strictEqual(first.ok, false, name);
|
|
1118
|
+
assert.strictEqual(h.scheduled.length, 1, `${name}: a retry stays armed after the failed write`);
|
|
1119
|
+
h.state.runningProcess = null;
|
|
1120
|
+
options.admitError = null;
|
|
1121
|
+
clock += 60_000;
|
|
1122
|
+
assert.strictEqual((await h.scheduled.at(-1).fn()).ok, true, `${name}: delivered at the next idle moment`);
|
|
1123
|
+
assert.strictEqual(h.jobsStore.get("w"), null, name);
|
|
1124
|
+
}
|
|
1125
|
+
assert.match(fs.readFileSync(process.env.OPEN_CLAUDIA_WATCH_LOG_FILE, "utf8"), /delivery bookkeeping failed \(ENOSPC\) — retrying in \d+ s/);
|
|
1126
|
+
|
|
1127
|
+
// An admission refusal with a working store is a counted, retried failure.
|
|
1128
|
+
const refused = schedulerHarness(createSchedulerService, [baseJob({ id: "refused" })], { admitError: new Error("no provider selected") });
|
|
1129
|
+
const refusal = await refused.service.fireJob("refused");
|
|
1130
|
+
assert.strictEqual(refusal.status, "failed");
|
|
1131
|
+
assert.strictEqual(refused.jobsStore.get("refused").attemptCount, 1);
|
|
1132
|
+
assert.strictEqual(refused.scheduled.length, 1);
|
|
1133
|
+
|
|
1134
|
+
// Pre-run failure records that cannot be written still keep a retry.
|
|
1135
|
+
for (const [name, overrides, options] of [
|
|
1136
|
+
["unknown adapter", { adapter: "gone", adapterType: "gone" }, {}],
|
|
1137
|
+
["no runnable provider", {}, { providerStatus: () => ({ availability: { state: "missing" }, auth: { state: "not_checked" } }) }],
|
|
1138
|
+
]) {
|
|
1139
|
+
const store = memoryJobsStore([baseJob({ id: "pre", ...overrides })]);
|
|
1140
|
+
store.update = () => { throw diskFull(); };
|
|
1141
|
+
const h = schedulerHarness(createSchedulerService, [], { jobsStore: store, ...options });
|
|
1142
|
+
assert.strictEqual((await h.service.fireJob("pre")).status, "failed", name);
|
|
1143
|
+
assert.strictEqual(h.scheduled.length, 1, `${name}: a retry stays armed`);
|
|
1144
|
+
assert.strictEqual(h.runCalls.length, 0);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// A running lease left by an earlier process is looked at again when it
|
|
1148
|
+
// lapses; fireJob already turns away a fire that is really in flight.
|
|
1149
|
+
const leased = schedulerHarness(createSchedulerService, [baseJob({ id: "leased", status: "running", attemptCount: 1, nextAttemptAt: 1_720_000_000_500 + 5000 })]);
|
|
1150
|
+
assert.strictEqual((await leased.service.fireJob("leased")).inFlight, true);
|
|
1151
|
+
assert.deepStrictEqual(leased.scheduled.map((timer) => timer.delay), [5000]);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
673
1154
|
async function durablePendingProbe(createSchedulerService, jobsApi) {
|
|
674
1155
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-pending-restart-"));
|
|
675
1156
|
let clock = 1_720_000_000_500;
|
|
@@ -706,7 +1187,7 @@ async function durablePendingProbe(createSchedulerService, jobsApi) {
|
|
|
706
1187
|
const terminal = deferred();
|
|
707
1188
|
const restarted = schedulerHarness(createSchedulerService, [], {
|
|
708
1189
|
jobsStore: reloaded, now: () => clock, outcomes: [terminal.promise, success],
|
|
709
|
-
runWatchCheck: async () => { throw new Error("
|
|
1190
|
+
runWatchCheck: async () => { throw new Error("a queued delivery must not re-run its trigger check after restart"); },
|
|
710
1191
|
});
|
|
711
1192
|
restarted.service.init();
|
|
712
1193
|
const firing = restarted.scheduled[0].fn();
|
|
@@ -1063,6 +1544,18 @@ function staticSchedulingProbe() {
|
|
|
1063
1544
|
assert.match(source["core/scheduler.js"], /watchExpired/, "scheduler must mark expiry fires distinctly");
|
|
1064
1545
|
assert.match(source["core/loopback.js"], /expiresAt/, "loopback must pass the watch expiry through");
|
|
1065
1546
|
assert.match(source["bin/schedule.js"], /--timeout/, "watch-add CLI must expose --timeout");
|
|
1547
|
+
|
|
1548
|
+
// Phase 0 (B2): a watch check must NOT inherit the bot's whole environment,
|
|
1549
|
+
// must build an explicit hardened env, and must register a creator-scoped
|
|
1550
|
+
// run around the poll.
|
|
1551
|
+
assert.ok(!/env:\s*process\.env/.test(source["core/scheduler.js"]), "watch check must not spawn with the bot's whole process env");
|
|
1552
|
+
assert.match(source["core/scheduler.js"], /buildWatchCheckEnv\(process\.env/, "watch check builds an explicit hardened env");
|
|
1553
|
+
assert.match(source["core/scheduler.js"], /registerRun\(checkRunId/, "watch check registers a creator-scoped run");
|
|
1554
|
+
assert.match(source["core/scheduler.js"], /endRun\(checkRunId\)/, "watch check ends its run when the check exits");
|
|
1555
|
+
// Phase 0 (B2): the loopback binds a job's identity to the run-attested
|
|
1556
|
+
// creator, not the payload's claims, and refuses an unattributed caller.
|
|
1557
|
+
assert.match(source["core/loopback.js"], /JOB_CREATE_KINDS/, "loopback must bind job creation to the run-attested caller");
|
|
1558
|
+
assert.match(source["core/loopback.js"], /createdBy/, "jobs must record their creator principal");
|
|
1066
1559
|
}
|
|
1067
1560
|
|
|
1068
1561
|
async function main() {
|
|
@@ -1072,6 +1565,8 @@ async function main() {
|
|
|
1072
1565
|
assert.strictEqual(typeof jobsApi.migrateJobsDocument, "function");
|
|
1073
1566
|
assert.strictEqual(typeof jobsApi.createJobsStore, "function");
|
|
1074
1567
|
assert.strictEqual(typeof schedulerApi.createSchedulerService, "function");
|
|
1568
|
+
assert.strictEqual(typeof schedulerApi.buildWatchCheckEnv, "function", "scheduler exports the watch-check env builder");
|
|
1569
|
+
assert.strictEqual(typeof schedulerApi.watchCheckPrincipal, "function", "scheduler exports the watch-check principal resolver");
|
|
1075
1570
|
|
|
1076
1571
|
migrationProbe(jobsApi);
|
|
1077
1572
|
legacyDefaultsProbe(jobsApi);
|
|
@@ -1083,6 +1578,12 @@ async function main() {
|
|
|
1083
1578
|
await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
|
|
1084
1579
|
await durablePendingProbe(schedulerApi.createSchedulerService, jobsApi);
|
|
1085
1580
|
await busyWatchNoticeProbe(schedulerApi.createSchedulerService);
|
|
1581
|
+
await pendingDeliveryPollingProbe(schedulerApi.createSchedulerService);
|
|
1582
|
+
await checkTimeoutAlarmProbe(schedulerApi.createSchedulerService);
|
|
1583
|
+
await farWakeupProbe(schedulerApi.createSchedulerService, schedulerApi.MAX_TIMER_DELAY_MS);
|
|
1584
|
+
wakeupHorizonCliProbe();
|
|
1585
|
+
listingProbe();
|
|
1586
|
+
await strandedDeliveryProbe(schedulerApi.createSchedulerService);
|
|
1086
1587
|
await unavailableAndFallbackProbe(schedulerApi.createSchedulerService);
|
|
1087
1588
|
await cronOccurrenceProbe(schedulerApi.createSchedulerService);
|
|
1088
1589
|
await watchGateProbe(schedulerApi.createSchedulerService);
|
|
@@ -95,6 +95,17 @@ try {
|
|
|
95
95
|
const claudeSettings = buildClaudeHookSettings();
|
|
96
96
|
assert.strictEqual(claudeSettings.hooks.PreToolUse[0].matcher, "Bash");
|
|
97
97
|
assert.match(claudeSettings.hooks.PreToolUse[0].hooks[0].command, /deny-gate-hook/);
|
|
98
|
+
// Claude's file-reading tools never pass through a shell, so the guarded
|
|
99
|
+
// read gate needs its own matcher on the same deny-gate entry.
|
|
100
|
+
const readHook = claudeSettings.hooks.PreToolUse.find((entry) => entry.matcher === "^(Read|Grep|Glob|NotebookRead|LS)$");
|
|
101
|
+
assert.ok(readHook, "Claude read tools are hooked");
|
|
102
|
+
assert.strictEqual(readHook.hooks[0].command, claudeSettings.hooks.PreToolUse[0].hooks[0].command);
|
|
103
|
+
for (const tool of ["Read", "Grep", "Glob", "NotebookRead", "LS"]) {
|
|
104
|
+
assert.ok(new RegExp(readHook.matcher).test(tool), `${tool} matches the read-gate matcher`);
|
|
105
|
+
}
|
|
106
|
+
for (const tool of ["Bash", "Write", "WebFetch", "ReadMcpResourceTool", "Task"]) {
|
|
107
|
+
assert.ok(!new RegExp(readHook.matcher).test(tool), `${tool} stays out of the read-gate matcher`);
|
|
108
|
+
}
|
|
98
109
|
assert.match(claudeSettings.hooks.PreToolUse[0].hooks[0].command, new RegExp(process.execPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
99
110
|
assert.strictEqual(ensureClaudeHookSettings({ settingsFile: claudeSettingsFile }), claudeSettingsFile);
|
|
100
111
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(claudeSettingsFile, "utf8")), claudeSettings);
|
package/test-run-lock.js
CHANGED
|
@@ -87,6 +87,8 @@ const { acquireRunLock } = require("./core/state");
|
|
|
87
87
|
'Queued behind the watch "Phone alarm" (running 42 min). /stop cancels it.');
|
|
88
88
|
assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: '[Scheduled wakeup "Check CI" fired at x]' }, t0 + 3 * 60_000),
|
|
89
89
|
'Queued behind the wakeup "Check CI" (running 3 min). /stop cancels it.');
|
|
90
|
+
assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: '[Watch check timing out "Phone health" fired at x; occurrence y]' }, t0 + 20 * 60_000),
|
|
91
|
+
'Queued behind the watch "Phone health" (running 20 min). /stop cancels it.', "a check-timeout alarm is a watch run too");
|
|
90
92
|
const long = queuedNotice({ admittedAt: t0, userPrompt: `[Watch triggered "${"x".repeat(90)}" fired at x]` }, t0 + 3 * 60_000);
|
|
91
93
|
assert.ok(long.includes(`"${"x".repeat(59)}…"`), "a long label is shortened to 60 characters");
|
|
92
94
|
}
|
package/test-tool-guard.js
CHANGED
|
@@ -122,7 +122,9 @@ const deny = guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { com
|
|
|
122
122
|
assert.strictEqual(deny.exit, 2, "deny → exit 2 (blocking)");
|
|
123
123
|
assert.ok(/deny-gate/.test(deny.stderr), "deny → stderr message for the model");
|
|
124
124
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la" } })).exit, 0, "benign Bash → allow");
|
|
125
|
-
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0, "
|
|
125
|
+
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0, "reads are never gated without channel context");
|
|
126
|
+
// Owner-local (no channel context) fails OPEN; guarded turns fail closed
|
|
127
|
+
// instead (pinned in test-read-guard.js).
|
|
126
128
|
assert.strictEqual(guard.runHook("not json{{{").exit, 0, "unparseable payload fails OPEN");
|
|
127
129
|
assert.strictEqual(guard.runHook(null).exit, 0, "null payload fails OPEN");
|
|
128
130
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash" })).exit, 0, "missing tool_input fails OPEN");
|
|
@@ -154,7 +156,7 @@ assert.strictEqual(
|
|
|
154
156
|
guard.runHook(JSON.stringify({ tool_name: "apply_patch", tool_input: { command: "*** Begin Patch\n*** Add File: /tmp/evil.txt\n+x\n*** End Patch\n" } })).exit, 2,
|
|
155
157
|
"Codex apply_patch equally gated on guarded turns (probed live: file edits arrive under this tool name)");
|
|
156
158
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0,
|
|
157
|
-
"reads stay free on guarded turns");
|
|
159
|
+
"ordinary reads stay free on guarded turns (protected roots: test-read-guard.js)");
|
|
158
160
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la" } })).exit, 0,
|
|
159
161
|
"benign Bash unaffected by the file gate (deny-gate rules unchanged)");
|
|
160
162
|
|
|
@@ -185,8 +187,11 @@ assert.strictEqual(guard.externalControlPlaneDenial(cpWrite), null, "no OC_CHANN
|
|
|
185
187
|
assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: cpWrite } })).exit, 0,
|
|
186
188
|
"owner-local shells keep full config access");
|
|
187
189
|
process.exitCode = 0;
|
|
190
|
+
delete process.env.OC_RUN_ATTEST; // hermetic even when the suite runs inside a live bot turn
|
|
188
191
|
require("./bin/keyring").run(["path"]);
|
|
189
|
-
assert.strictEqual(process.exitCode
|
|
192
|
+
assert.strictEqual(process.exitCode, 1,
|
|
193
|
+
"keyring CLI is owner-only even with no channel env: no live owner run behind the process = unattributed (test-cli-owner-gates.js)");
|
|
194
|
+
process.exitCode = 0;
|
|
190
195
|
process.env.OC_CHANNEL_ADAPTER = "telegram";
|
|
191
196
|
process.env.OC_CHANNEL_ID = "999000111";
|
|
192
197
|
process.env.OC_CHANNEL_USER_ID = "999000111";
|
|
@@ -288,6 +293,10 @@ assert.strictEqual(pre[1].matcher, "^(Write|Edit|MultiEdit|NotebookEdit)$",
|
|
|
288
293
|
"file-mutation tools are hooked for the external-speaker gate");
|
|
289
294
|
assert.strictEqual(pre[1].hooks[0].command, pre[0].hooks[0].command,
|
|
290
295
|
"file gate rides the same deny-gate-hook entry (one CLI, one audit path)");
|
|
296
|
+
assert.strictEqual(pre[2].matcher, "^(Read|Grep|Glob|NotebookRead|LS)$",
|
|
297
|
+
"file-reading tools are hooked for the guarded read gate");
|
|
298
|
+
assert.strictEqual(pre[2].hooks[0].command, pre[0].hooks[0].command,
|
|
299
|
+
"read gate rides the same deny-gate-hook entry");
|
|
291
300
|
// second call with the file read-only: content-compare must skip the write
|
|
292
301
|
fs.chmodSync(sPath, 0o444);
|
|
293
302
|
assert.strictEqual(guard.ensureHookSettings(), sPath, "unchanged settings are not rewritten");
|