switchroom 0.19.23 → 0.19.25
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/dist/agent-scheduler/index.js +18 -7
- package/dist/auth-broker/index.js +117 -33
- package/dist/cli/autoaccept-poll.js +0 -1
- package/dist/cli/drive-write-pretool.mjs +5 -0
- package/dist/cli/ms-365-write-pretool.mjs +5 -0
- package/dist/cli/notion-write-pretool.mjs +18 -6
- package/dist/cli/switchroom.js +2916 -1481
- package/dist/host-control/main.js +116 -34
- package/dist/vault/approvals/kernel-server.js +115 -33
- package/dist/vault/broker/server.js +281 -76
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +52 -13
- package/profiles/_shared/dev-protocol.md.hbs +3 -4
- package/skills/dev-protocol/SKILL.md +22 -15
- package/skills/switchroom-health/SKILL.md +19 -0
- package/skills/switchroom-release/SKILL.md +2 -1
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -2
- package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
- package/telegram-plugin/gateway/gateway.ts +34 -35
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/quota-bar-format.ts +4 -1
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
- package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
- package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
- package/vendor/hindsight-memory/scripts/recall.py +503 -252
- package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
- package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
- package/vendor/hindsight-memory/tests/test_content.py +218 -0
|
@@ -34,11 +34,14 @@ import {
|
|
|
34
34
|
resolveReplyOwnerTurnId,
|
|
35
35
|
resolveReplyOwnerTier,
|
|
36
36
|
decideAnswerLatchSuppression,
|
|
37
|
+
decideContentGateBypass,
|
|
37
38
|
type ReplyOwnerCandidates,
|
|
39
|
+
type ReplyOwnerTier,
|
|
38
40
|
type AnswerDeliveredLatch,
|
|
39
41
|
} from '../reply-owner-resolve.js'
|
|
40
42
|
import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
|
|
41
43
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
44
|
+
import { latestTurnForChat } from '../gateway/latest-turn-lookup.js'
|
|
42
45
|
|
|
43
46
|
const NONE: ReplyOwnerCandidates = {
|
|
44
47
|
liveTurnId: null,
|
|
@@ -685,3 +688,531 @@ describe('resolveReplyOwnerTier — precedence + latest-ended TTL bound', () =>
|
|
|
685
688
|
}
|
|
686
689
|
})
|
|
687
690
|
})
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* `decideContentGateBypass` — WHEN the supersede path may treat a landing reply
|
|
694
|
+
* as this flushed turn's own answer and collapse the provisional flush even
|
|
695
|
+
* though the model REWORDED it.
|
|
696
|
+
*
|
|
697
|
+
* The rule replaced a blanket ban on the model-supplied `origin`/`quoted` tiers
|
|
698
|
+
* with CORROBORATION against the framework-derived `latestEndedTurnId`. The two
|
|
699
|
+
* properties that matter, both asserted below as outcomes:
|
|
700
|
+
*
|
|
701
|
+
* - it closes the 2026-07-27 duplicate: a reply that echoes `origin_turn_id`
|
|
702
|
+
* for its OWN turn now bypasses, exactly as it would have by omitting the
|
|
703
|
+
* echo (the blanket ban punished the extra information);
|
|
704
|
+
* - it grants ZERO new capability: for every candidate shape, a corroborated
|
|
705
|
+
* `origin`/`quoted` bypass is reachable by dropping the model-supplied ids
|
|
706
|
+
* and landing on `latest-ended` — so nothing bypasses that could not
|
|
707
|
+
* already, and a STEERED attribution (pointing at a different turn than the
|
|
708
|
+
* framework resolves) still cannot.
|
|
709
|
+
*/
|
|
710
|
+
describe('decideContentGateBypass — corroborated bypass of the #3429 content gate', () => {
|
|
711
|
+
const OWNER = 'turn-OWNER'
|
|
712
|
+
const OTHER = 'turn-OTHER'
|
|
713
|
+
|
|
714
|
+
/** Candidates whose framework-derived latest-ended IS `latestEnded`, fresh. */
|
|
715
|
+
const cands = (over: Partial<ReplyOwnerCandidates> = {}): ReplyOwnerCandidates => ({
|
|
716
|
+
liveTurnId: null,
|
|
717
|
+
originTurnId: null,
|
|
718
|
+
quotedTurnId: null,
|
|
719
|
+
latestEndedTurnId: OWNER,
|
|
720
|
+
latestEndedAgeMs: 30_000,
|
|
721
|
+
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
722
|
+
...over,
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('THE 2026-07-27 INCIDENT: an `origin` echo pointing at the SAME turn the ' +
|
|
726
|
+
'framework resolves bypasses the gate (pre-fix: banned → visible duplicate)', () => {
|
|
727
|
+
expect(
|
|
728
|
+
decideContentGateBypass({
|
|
729
|
+
tier: 'origin',
|
|
730
|
+
resolvedTurnId: OWNER,
|
|
731
|
+
candidates: cands({ originTurnId: OWNER }),
|
|
732
|
+
handbackCouldOwnReply: false,
|
|
733
|
+
}),
|
|
734
|
+
).toBe(true)
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
it('a `quoted` attribution corroborated the same way also bypasses', () => {
|
|
738
|
+
expect(
|
|
739
|
+
decideContentGateBypass({
|
|
740
|
+
tier: 'quoted',
|
|
741
|
+
resolvedTurnId: OWNER,
|
|
742
|
+
candidates: cands({ quotedTurnId: OWNER }),
|
|
743
|
+
handbackCouldOwnReply: false,
|
|
744
|
+
}),
|
|
745
|
+
).toBe(true)
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
it('THE FABLE VECTOR stays closed: an `origin`/`quoted` attribution STEERED onto ' +
|
|
749
|
+
'a different turn than the framework resolves never bypasses', () => {
|
|
750
|
+
for (const tier of ['origin', 'quoted'] as const) {
|
|
751
|
+
expect(
|
|
752
|
+
decideContentGateBypass({
|
|
753
|
+
tier,
|
|
754
|
+
resolvedTurnId: OTHER,
|
|
755
|
+
candidates: cands({
|
|
756
|
+
originTurnId: tier === 'origin' ? OTHER : null,
|
|
757
|
+
quotedTurnId: tier === 'quoted' ? OTHER : null,
|
|
758
|
+
latestEndedTurnId: OWNER,
|
|
759
|
+
}),
|
|
760
|
+
handbackCouldOwnReply: false,
|
|
761
|
+
}),
|
|
762
|
+
).toBe(false)
|
|
763
|
+
}
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('a decoupled completion in the window keeps the gate on EVERY non-live tier', () => {
|
|
767
|
+
for (const tier of ['origin', 'quoted', 'latest-ended'] as const) {
|
|
768
|
+
expect(
|
|
769
|
+
decideContentGateBypass({
|
|
770
|
+
tier,
|
|
771
|
+
resolvedTurnId: OWNER,
|
|
772
|
+
candidates: cands({
|
|
773
|
+
originTurnId: tier === 'origin' ? OWNER : null,
|
|
774
|
+
quotedTurnId: tier === 'quoted' ? OWNER : null,
|
|
775
|
+
}),
|
|
776
|
+
handbackCouldOwnReply: true,
|
|
777
|
+
}),
|
|
778
|
+
).toBe(false)
|
|
779
|
+
}
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
it('`live` bypasses unconditionally — even inside a handback window ' +
|
|
783
|
+
'(framework-owned, and decideSupersede bars it from another turn record)', () => {
|
|
784
|
+
expect(
|
|
785
|
+
decideContentGateBypass({
|
|
786
|
+
tier: 'live',
|
|
787
|
+
resolvedTurnId: OWNER,
|
|
788
|
+
candidates: cands({ liveTurnId: OWNER }),
|
|
789
|
+
handbackCouldOwnReply: true,
|
|
790
|
+
}),
|
|
791
|
+
).toBe(true)
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
it('`none` never bypasses (nothing to attribute the reply to)', () => {
|
|
795
|
+
expect(
|
|
796
|
+
decideContentGateBypass({
|
|
797
|
+
tier: 'none',
|
|
798
|
+
resolvedTurnId: null,
|
|
799
|
+
candidates: cands({ latestEndedTurnId: null }),
|
|
800
|
+
handbackCouldOwnReply: false,
|
|
801
|
+
}),
|
|
802
|
+
).toBe(false)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('a STALE framework candidate corroborates NOTHING — the F2 freshness bound ' +
|
|
806
|
+
'still denies a past-TTL turn any bypass authority', () => {
|
|
807
|
+
const stale = cands({
|
|
808
|
+
originTurnId: OWNER,
|
|
809
|
+
latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1,
|
|
810
|
+
})
|
|
811
|
+
expect(
|
|
812
|
+
decideContentGateBypass({
|
|
813
|
+
tier: 'origin',
|
|
814
|
+
resolvedTurnId: OWNER,
|
|
815
|
+
candidates: stale,
|
|
816
|
+
handbackCouldOwnReply: false,
|
|
817
|
+
}),
|
|
818
|
+
).toBe(false)
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('`latest-ended` behaviour is UNCHANGED by the widening (it corroborates itself)', () => {
|
|
822
|
+
expect(
|
|
823
|
+
decideContentGateBypass({
|
|
824
|
+
tier: 'latest-ended',
|
|
825
|
+
resolvedTurnId: OWNER,
|
|
826
|
+
candidates: cands(),
|
|
827
|
+
handbackCouldOwnReply: false,
|
|
828
|
+
}),
|
|
829
|
+
).toBe(true)
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
// ─── #3726: total over degenerate input, and it fails CLOSED ───
|
|
833
|
+
//
|
|
834
|
+
// TypeScript makes `candidates` mandatory and the gateway always constructs
|
|
835
|
+
// it, so this is production-unreachable — but the function is EXPORTED, and a
|
|
836
|
+
// caller without the gateway's construction discipline used to get a
|
|
837
|
+
// `TypeError` out of the supersede decision path (fail-open-by-crash) instead
|
|
838
|
+
// of the safe answer. The safe answer is `false`: keep the #3429 content gate.
|
|
839
|
+
it('#3726: an absent candidate set returns false (fail CLOSED), never throws', () => {
|
|
840
|
+
for (const tier of ['origin', 'quoted', 'latest-ended'] as const) {
|
|
841
|
+
for (const absent of [undefined, null]) {
|
|
842
|
+
expect(
|
|
843
|
+
decideContentGateBypass({
|
|
844
|
+
tier,
|
|
845
|
+
resolvedTurnId: OWNER,
|
|
846
|
+
candidates: absent as unknown as ReplyOwnerCandidates,
|
|
847
|
+
handbackCouldOwnReply: false,
|
|
848
|
+
}),
|
|
849
|
+
).toBe(false)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
// ─── ZERO new capability, as an EXHAUSTIVE property (#3724) ───
|
|
855
|
+
//
|
|
856
|
+
// The safety argument for widening `origin`/`quoted` from a blanket ban to
|
|
857
|
+
// corroboration: every shape that bypasses via a model-supplied tier ALSO
|
|
858
|
+
// bypasses with those model-supplied ids stripped (i.e. by the model simply
|
|
859
|
+
// omitting `origin_turn_id` / `reply_to`). So the widening hands a reply no
|
|
860
|
+
// reach it did not already have.
|
|
861
|
+
//
|
|
862
|
+
// #3724 — the previous form of this test hand-picked four candidate shapes and
|
|
863
|
+
// guarded its assertion behind `if (withModelIds)`, so one of the four
|
|
864
|
+
// asserted nothing at all, `handbackCouldOwnReply` was pinned `false`, and
|
|
865
|
+
// `liveTurnId` was never varied. It could only go red if the corroboration
|
|
866
|
+
// equality were deleted outright. It is replaced here by an exhaustive sweep
|
|
867
|
+
// of the whole (small) candidate space that derives `tier`/`resolvedTurnId`
|
|
868
|
+
// from the REAL resolvers rather than passing them in by hand — and, so the
|
|
869
|
+
// sweep is evidence rather than decoration, by a companion test that runs the
|
|
870
|
+
// SAME sweep over deliberately broken decisions and asserts it catches them.
|
|
871
|
+
const IDS = [null, OWNER, OTHER] as const
|
|
872
|
+
/** fresh / stale / unbounded (no age+ttl — the documented back-compat path). */
|
|
873
|
+
const AGES = [
|
|
874
|
+
{ label: 'fresh', ageMs: 30_000 as number | null, ttlMs: DEFAULT_SUPERSEDE_TTL_MS as number | undefined },
|
|
875
|
+
{ label: 'stale', ageMs: DEFAULT_SUPERSEDE_TTL_MS + 1, ttlMs: DEFAULT_SUPERSEDE_TTL_MS },
|
|
876
|
+
{ label: 'unbounded', ageMs: null, ttlMs: undefined },
|
|
877
|
+
]
|
|
878
|
+
|
|
879
|
+
type Decide = typeof decideContentGateBypass
|
|
880
|
+
|
|
881
|
+
/** The module's freshness rule, restated locally so the mutants below can
|
|
882
|
+
* differ from the real decision in EXACTLY one respect each. */
|
|
883
|
+
const freshEnough = (c: ReplyOwnerCandidates): boolean =>
|
|
884
|
+
c.latestEndedTurnId != null &&
|
|
885
|
+
(c.latestEndedAgeMs == null || c.latestEndedTtlMs == null
|
|
886
|
+
? true
|
|
887
|
+
: c.latestEndedAgeMs <= c.latestEndedTtlMs)
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Sweep the full candidate space against `decide`, reporting every case that
|
|
891
|
+
* VIOLATES zero-new-capability (bypasses with the model-supplied ids present
|
|
892
|
+
* but not with them stripped) plus coverage counters that stop the sweep
|
|
893
|
+
* silently degrading to all-vacuous.
|
|
894
|
+
*/
|
|
895
|
+
function sweepZeroNewCapability(decide: Decide): {
|
|
896
|
+
violations: string[]
|
|
897
|
+
cases: number
|
|
898
|
+
bypasses: number
|
|
899
|
+
modelTierBypasses: number
|
|
900
|
+
} {
|
|
901
|
+
const violations: string[] = []
|
|
902
|
+
let cases = 0
|
|
903
|
+
let bypasses = 0
|
|
904
|
+
let modelTierBypasses = 0
|
|
905
|
+
for (const liveTurnId of IDS)
|
|
906
|
+
for (const originTurnId of IDS)
|
|
907
|
+
for (const quotedTurnId of IDS)
|
|
908
|
+
for (const latestEndedTurnId of IDS)
|
|
909
|
+
for (const age of AGES)
|
|
910
|
+
for (const handbackCouldOwnReply of [false, true]) {
|
|
911
|
+
cases++
|
|
912
|
+
const c: ReplyOwnerCandidates = {
|
|
913
|
+
liveTurnId,
|
|
914
|
+
originTurnId,
|
|
915
|
+
quotedTurnId,
|
|
916
|
+
latestEndedTurnId,
|
|
917
|
+
latestEndedAgeMs: age.ageMs,
|
|
918
|
+
latestEndedTtlMs: age.ttlMs,
|
|
919
|
+
}
|
|
920
|
+
const tier = resolveReplyOwnerTier(c)
|
|
921
|
+
const withModelIds = decide({
|
|
922
|
+
tier,
|
|
923
|
+
resolvedTurnId: resolveReplyOwnerTurnId(c),
|
|
924
|
+
candidates: c,
|
|
925
|
+
handbackCouldOwnReply,
|
|
926
|
+
})
|
|
927
|
+
// Strip the model-supplied ids: the FRAMEWORK alone resolves the
|
|
928
|
+
// owner — the reach the model already had by staying silent.
|
|
929
|
+
const stripped: ReplyOwnerCandidates = {
|
|
930
|
+
...c,
|
|
931
|
+
originTurnId: null,
|
|
932
|
+
quotedTurnId: null,
|
|
933
|
+
}
|
|
934
|
+
const withoutModelIds = decide({
|
|
935
|
+
tier: resolveReplyOwnerTier(stripped),
|
|
936
|
+
resolvedTurnId: resolveReplyOwnerTurnId(stripped),
|
|
937
|
+
candidates: stripped,
|
|
938
|
+
handbackCouldOwnReply,
|
|
939
|
+
})
|
|
940
|
+
if (withModelIds) {
|
|
941
|
+
bypasses++
|
|
942
|
+
if (tier === 'origin' || tier === 'quoted') modelTierBypasses++
|
|
943
|
+
}
|
|
944
|
+
if (withModelIds && !withoutModelIds) {
|
|
945
|
+
violations.push(
|
|
946
|
+
`tier=${tier} live=${liveTurnId} origin=${originTurnId} ` +
|
|
947
|
+
`quoted=${quotedTurnId} latestEnded=${latestEndedTurnId} ` +
|
|
948
|
+
`age=${age.label} handback=${handbackCouldOwnReply}`,
|
|
949
|
+
)
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return { violations, cases, bypasses, modelTierBypasses }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
it('ZERO new capability over the EXHAUSTIVE candidate space: no shape bypasses ' +
|
|
956
|
+
'with model-supplied ids that would not bypass without them', () => {
|
|
957
|
+
const r = sweepZeroNewCapability(decideContentGateBypass)
|
|
958
|
+
// The property itself — every violating shape is NAMED, not just counted.
|
|
959
|
+
expect(r.violations).toEqual([])
|
|
960
|
+
// Non-degeneracy: the sweep must actually cover the space and must actually
|
|
961
|
+
// reach the branch under test, so it can never quietly become a no-op.
|
|
962
|
+
expect(r.cases).toBe(IDS.length ** 4 * AGES.length * 2)
|
|
963
|
+
expect(r.bypasses).toBeGreaterThan(0)
|
|
964
|
+
// …and specifically, many cases must win their bypass on the WIDENED,
|
|
965
|
+
// model-steerable `origin`/`quoted` tiers — the thing being vouched for.
|
|
966
|
+
// Exactly 8 of the 486 cases do: no live turn, handback clear, a FRESH
|
|
967
|
+
// latest-ended id, and the steered id equal to it.
|
|
968
|
+
//
|
|
969
|
+
// This floor was 16 before #3725. The other 8 were the `latestEndedAgeMs
|
|
970
|
+
// == null` (unbounded) shapes, which corroborated a bypass off an anchor
|
|
971
|
+
// that had never ended. #3725 makes the latest-ended lookup `endedOnly`, so
|
|
972
|
+
// the age is always a real number and an explicit null age fails CLOSED —
|
|
973
|
+
// those 8 shapes are now correctly unreachable. Lowering the floor here is
|
|
974
|
+
// recording that narrowing, not conceding it: the property assertion above
|
|
975
|
+
// (`violations` empty) is unchanged and still passes.
|
|
976
|
+
expect(r.modelTierBypasses).toBeGreaterThanOrEqual(8)
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
// NON-VACUITY, asserted deterministically rather than demonstrated by hand:
|
|
980
|
+
// the same sweep is run over mutants that break zero-new-capability in the two
|
|
981
|
+
// realistic ways #3724 called out. If it could not go red on these, it would
|
|
982
|
+
// not be evidence for the real function.
|
|
983
|
+
it('the sweep is NOT vacuous: it catches a bypass that ignores corroboration, ' +
|
|
984
|
+
'and one that ignores the handback window on the steerable tiers', () => {
|
|
985
|
+
// Mutant A — the widening WITHOUT the corroboration equality: `origin`/
|
|
986
|
+
// `quoted` bypass on any resolved turn. This is the Fable silent-edit-over.
|
|
987
|
+
const ignoresCorroboration: Decide = (input) => {
|
|
988
|
+
if (input.tier === 'live') return true
|
|
989
|
+
if (input.tier === 'none') return false
|
|
990
|
+
if (input.handbackCouldOwnReply) return false
|
|
991
|
+
if (input.candidates == null) return false
|
|
992
|
+
if (input.tier === 'origin' || input.tier === 'quoted') return true
|
|
993
|
+
if (!freshEnough(input.candidates)) return false
|
|
994
|
+
return input.resolvedTurnId === input.candidates.latestEndedTurnId
|
|
995
|
+
}
|
|
996
|
+
const a = sweepZeroNewCapability(ignoresCorroboration)
|
|
997
|
+
expect(a.violations.length).toBeGreaterThan(0)
|
|
998
|
+
|
|
999
|
+
// Mutant B — corroboration kept, handback guard dropped on the steerable
|
|
1000
|
+
// tiers only. The OLD hand-picked test stayed GREEN under exactly this,
|
|
1001
|
+
// because it pinned `handbackCouldOwnReply: false` in every shape.
|
|
1002
|
+
const ignoresHandback: Decide = (input) => {
|
|
1003
|
+
if (input.tier === 'live') return true
|
|
1004
|
+
if (input.tier === 'none') return false
|
|
1005
|
+
const steerable = input.tier === 'origin' || input.tier === 'quoted'
|
|
1006
|
+
if (input.handbackCouldOwnReply && !steerable) return false
|
|
1007
|
+
if (input.candidates == null) return false
|
|
1008
|
+
if (!freshEnough(input.candidates)) return false
|
|
1009
|
+
return (
|
|
1010
|
+
input.resolvedTurnId != null &&
|
|
1011
|
+
input.resolvedTurnId === input.candidates.latestEndedTurnId
|
|
1012
|
+
)
|
|
1013
|
+
}
|
|
1014
|
+
const b = sweepZeroNewCapability(ignoresHandback)
|
|
1015
|
+
expect(b.violations.length).toBeGreaterThan(0)
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* #3725 — the "framework-derived, TTL-bounded" supersede anchor was neither
|
|
1021
|
+
* ended nor bounded.
|
|
1022
|
+
*
|
|
1023
|
+
* The gateway's `recentTurnsById` registry is populated at turn START, so its
|
|
1024
|
+
* tail entry for a chat can be a turn that is STILL RUNNING (`endedAt: null`) —
|
|
1025
|
+
* and because the registry is chat-wide and thread-agnostic, a turn running in
|
|
1026
|
+
* another topic of the same chat lands there. The gateway then computed
|
|
1027
|
+
* `latestEndedAgeMs = endedAt != null ? now - endedAt : null`, and
|
|
1028
|
+
* `latestEndedAccepted` treated a null age as UNBOUNDED (its back-compat escape
|
|
1029
|
+
* for callers that supply no age at all). Net effect: a still-running turn
|
|
1030
|
+
* became a corroborating anchor with NO freshness bound, for the widened
|
|
1031
|
+
* `origin`/`quoted` content-gate bypass #3719 leans on.
|
|
1032
|
+
*
|
|
1033
|
+
* The fix is two-layer and both layers fail CLOSED:
|
|
1034
|
+
* 1. the gateway lookup skips turns with no `endedAt` (`latestTurnForChat`
|
|
1035
|
+
* with `endedOnly: true`), so the anchor is a genuinely ended turn — and
|
|
1036
|
+
* when the chat has no ended turn, there is NO anchor rather than a
|
|
1037
|
+
* running one;
|
|
1038
|
+
* 2. `latestEndedAccepted` distinguishes an OMITTED age (undefined ⇒
|
|
1039
|
+
* back-compat unbounded) from a COMPUTED-null age (⇒ rejected).
|
|
1040
|
+
*/
|
|
1041
|
+
describe('#3725 — a still-running turn is not a supersede anchor', () => {
|
|
1042
|
+
const CHAT = '12345'
|
|
1043
|
+
const ENDED = 'turn-ENDED'
|
|
1044
|
+
const RUNNING = 'turn-RUNNING'
|
|
1045
|
+
|
|
1046
|
+
interface RegistryTurn { turnId: string; sessionChatId: string; endedAt: number | null }
|
|
1047
|
+
|
|
1048
|
+
const NOW = 1_000_000
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* The gateway's candidate construction (`resolveReplyOwnerTurn`), mirrored
|
|
1052
|
+
* here because `gateway.ts` is not importable in tests — the same convention
|
|
1053
|
+
* the rest of this file uses. `scan` selects the FIXED lookup (`endedOnly`)
|
|
1054
|
+
* or the PRE-FIX one (tail entry regardless of `endedAt`), giving the
|
|
1055
|
+
* red-on-main contrast as an assertion rather than prose.
|
|
1056
|
+
*/
|
|
1057
|
+
function gatewayCandidates(
|
|
1058
|
+
turns: RegistryTurn[],
|
|
1059
|
+
scan: 'fixed' | 'prefix',
|
|
1060
|
+
over: Partial<ReplyOwnerCandidates> = {},
|
|
1061
|
+
): ReplyOwnerCandidates {
|
|
1062
|
+
const latestEnded =
|
|
1063
|
+
scan === 'fixed'
|
|
1064
|
+
? latestTurnForChat(turns, CHAT, { endedOnly: true })
|
|
1065
|
+
: (turns.filter((t) => t.sessionChatId === CHAT).at(-1) ?? null)
|
|
1066
|
+
return {
|
|
1067
|
+
liveTurnId: null,
|
|
1068
|
+
originTurnId: null,
|
|
1069
|
+
quotedTurnId: null,
|
|
1070
|
+
latestEndedTurnId: latestEnded?.turnId ?? null,
|
|
1071
|
+
latestEndedAgeMs: latestEnded?.endedAt != null ? NOW - latestEnded.endedAt : null,
|
|
1072
|
+
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
1073
|
+
...over,
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* The PRE-FIX resolver semantics, reproduced verbatim for the red-on-main
|
|
1079
|
+
* contrast. BOTH layers of the defect are here: `latestEndedAccepted` treated
|
|
1080
|
+
* a null age as unbounded (`age == null ⇒ true`), and the gateway's scan (the
|
|
1081
|
+
* `'prefix'` mode above) handed it a turn that had not ended, whose age it
|
|
1082
|
+
* therefore computed as null.
|
|
1083
|
+
*/
|
|
1084
|
+
function preFixAccepted(c: ReplyOwnerCandidates): boolean {
|
|
1085
|
+
if (c.latestEndedTurnId == null) return false
|
|
1086
|
+
const age = c.latestEndedAgeMs
|
|
1087
|
+
const ttl = c.latestEndedTtlMs
|
|
1088
|
+
if (age == null || ttl == null) return true
|
|
1089
|
+
return age <= ttl
|
|
1090
|
+
}
|
|
1091
|
+
function preFixTier(c: ReplyOwnerCandidates): ReplyOwnerTier {
|
|
1092
|
+
if (c.liveTurnId != null) return 'live'
|
|
1093
|
+
if (c.originTurnId != null) return 'origin'
|
|
1094
|
+
if (c.quotedTurnId != null) return 'quoted'
|
|
1095
|
+
if (preFixAccepted(c)) return 'latest-ended'
|
|
1096
|
+
return 'none'
|
|
1097
|
+
}
|
|
1098
|
+
function preFixOwnerId(c: ReplyOwnerCandidates): string | null {
|
|
1099
|
+
switch (preFixTier(c)) {
|
|
1100
|
+
case 'live': return c.liveTurnId
|
|
1101
|
+
case 'origin': return c.originTurnId
|
|
1102
|
+
case 'quoted': return c.quotedTurnId
|
|
1103
|
+
case 'latest-ended': return c.latestEndedTurnId
|
|
1104
|
+
case 'none': return null
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/** `decideContentGateBypass` as it stood before this fix (same rule, pre-fix
|
|
1108
|
+
* acceptance). */
|
|
1109
|
+
function preFixBypass(c: ReplyOwnerCandidates): boolean {
|
|
1110
|
+
const tier = preFixTier(c)
|
|
1111
|
+
if (tier === 'live') return true
|
|
1112
|
+
if (tier === 'none') return false
|
|
1113
|
+
if (!preFixAccepted(c)) return false
|
|
1114
|
+
const id = preFixOwnerId(c)
|
|
1115
|
+
return id != null && id === c.latestEndedTurnId
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** A chat whose only ended turn is followed by a turn still running in
|
|
1119
|
+
* another topic — the registry tail is the RUNNING turn. */
|
|
1120
|
+
const registry: RegistryTurn[] = [
|
|
1121
|
+
{ turnId: ENDED, sessionChatId: CHAT, endedAt: NOW - 10_000 },
|
|
1122
|
+
{ turnId: RUNNING, sessionChatId: CHAT, endedAt: null },
|
|
1123
|
+
]
|
|
1124
|
+
|
|
1125
|
+
it('the anchor is the ENDED turn, not the running tail entry (pre-fix scan ' +
|
|
1126
|
+
'picked the running turn with a null — unbounded — age)', () => {
|
|
1127
|
+
expect(gatewayCandidates(registry, 'fixed')).toMatchObject({
|
|
1128
|
+
latestEndedTurnId: ENDED,
|
|
1129
|
+
latestEndedAgeMs: 10_000,
|
|
1130
|
+
})
|
|
1131
|
+
expect(gatewayCandidates(registry, 'prefix')).toMatchObject({
|
|
1132
|
+
latestEndedTurnId: RUNNING,
|
|
1133
|
+
latestEndedAgeMs: null,
|
|
1134
|
+
})
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
it('a model-steered `origin` echo of the RUNNING turn gets NO corroborated ' +
|
|
1138
|
+
'bypass — pre-fix it did', () => {
|
|
1139
|
+
const steered = { originTurnId: RUNNING }
|
|
1140
|
+
const fixed = gatewayCandidates(registry, 'fixed', steered)
|
|
1141
|
+
expect(
|
|
1142
|
+
decideContentGateBypass({
|
|
1143
|
+
tier: resolveReplyOwnerTier(fixed),
|
|
1144
|
+
resolvedTurnId: resolveReplyOwnerTurnId(fixed),
|
|
1145
|
+
candidates: fixed,
|
|
1146
|
+
handbackCouldOwnReply: false,
|
|
1147
|
+
}),
|
|
1148
|
+
).toBe(false)
|
|
1149
|
+
// Red-on-main contrast: the same reply DID bypass the #3429 content gate
|
|
1150
|
+
// before the fix, corroborated by a turn that had not even ended.
|
|
1151
|
+
expect(preFixBypass(gatewayCandidates(registry, 'prefix', steered))).toBe(true)
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
it('a chat with ONLY a running turn yields NO owner and NO bypass (fail ' +
|
|
1155
|
+
'closed), where pre-fix it yielded an unbounded latest-ended owner', () => {
|
|
1156
|
+
const onlyRunning: RegistryTurn[] = [{ turnId: RUNNING, sessionChatId: CHAT, endedAt: null }]
|
|
1157
|
+
const fixed = gatewayCandidates(onlyRunning, 'fixed')
|
|
1158
|
+
expect(fixed.latestEndedTurnId).toBeNull()
|
|
1159
|
+
expect(resolveReplyOwnerTier(fixed)).toBe('none')
|
|
1160
|
+
expect(resolveReplyOwnerTurnId(fixed)).toBeNull()
|
|
1161
|
+
expect(
|
|
1162
|
+
decideContentGateBypass({
|
|
1163
|
+
tier: 'none',
|
|
1164
|
+
resolvedTurnId: null,
|
|
1165
|
+
candidates: fixed,
|
|
1166
|
+
handbackCouldOwnReply: false,
|
|
1167
|
+
}),
|
|
1168
|
+
).toBe(false)
|
|
1169
|
+
|
|
1170
|
+
const prefix = gatewayCandidates(onlyRunning, 'prefix')
|
|
1171
|
+
expect(preFixTier(prefix)).toBe('latest-ended')
|
|
1172
|
+
expect(preFixOwnerId(prefix)).toBe(RUNNING)
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
it('layer 2: an EXPLICIT null age is rejected outright, while an OMITTED age ' +
|
|
1176
|
+
'keeps the pre-F2 back-compat escape', () => {
|
|
1177
|
+
const base: ReplyOwnerCandidates = {
|
|
1178
|
+
liveTurnId: null,
|
|
1179
|
+
originTurnId: null,
|
|
1180
|
+
quotedTurnId: null,
|
|
1181
|
+
latestEndedTurnId: ENDED,
|
|
1182
|
+
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
1183
|
+
}
|
|
1184
|
+
// Computed-null (the caller's candidate turn has not ended) ⇒ closed.
|
|
1185
|
+
expect(resolveReplyOwnerTier({ ...base, latestEndedAgeMs: null })).toBe('none')
|
|
1186
|
+
expect(resolveReplyOwnerTurnId({ ...base, latestEndedAgeMs: null })).toBeNull()
|
|
1187
|
+
expect(
|
|
1188
|
+
decideContentGateBypass({
|
|
1189
|
+
tier: 'origin',
|
|
1190
|
+
resolvedTurnId: ENDED,
|
|
1191
|
+
candidates: { ...base, originTurnId: ENDED, latestEndedAgeMs: null },
|
|
1192
|
+
handbackCouldOwnReply: false,
|
|
1193
|
+
}),
|
|
1194
|
+
).toBe(false)
|
|
1195
|
+
// Property omitted entirely ⇒ unchanged pre-F2 behaviour.
|
|
1196
|
+
expect(resolveReplyOwnerTier(base)).toBe('latest-ended')
|
|
1197
|
+
expect(resolveReplyOwnerTurnId(base)).toBe(ENDED)
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
it('end-to-end: a late reply while another topic is still running cannot ' +
|
|
1201
|
+
"delete a DIFFERENT turn's flush record", () => {
|
|
1202
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
1203
|
+
// The still-running turn holds a fresh flush record of its own.
|
|
1204
|
+
reg.record(CHAT, undefined, { turnId: RUNNING, messageIds: [9001], text: 'A' }, NOW)
|
|
1205
|
+
// A late reply lands with no live turn, no echo, no quote. Pre-fix its owner
|
|
1206
|
+
// resolved to the RUNNING turn (registry tail, unbounded) and the take()
|
|
1207
|
+
// consumed that turn's record; with the fix the owner is the ENDED turn, so
|
|
1208
|
+
// the running turn's record is untouched.
|
|
1209
|
+
const onlyRunning: RegistryTurn[] = [
|
|
1210
|
+
{ turnId: ENDED, sessionChatId: CHAT, endedAt: NOW - 10_000 },
|
|
1211
|
+
{ turnId: RUNNING, sessionChatId: CHAT, endedAt: null },
|
|
1212
|
+
]
|
|
1213
|
+
const ownerId = resolveReplyOwnerTurnId(gatewayCandidates(onlyRunning, 'fixed'))
|
|
1214
|
+
expect(ownerId).toBe(ENDED)
|
|
1215
|
+
expect(reg.take(CHAT, undefined, { liveTurnId: ownerId, now: NOW + 10 }).supersede).toBe(false)
|
|
1216
|
+
expect(reg.peek(CHAT, undefined, { liveTurnId: RUNNING, now: NOW + 10 }).supersede).toBe(true)
|
|
1217
|
+
})
|
|
1218
|
+
})
|
|
@@ -82,7 +82,7 @@ describe("secret-detect — DOES still fire on actually-shaped secrets (regressi
|
|
|
82
82
|
// Locking in so a future regex-tightening doesn't over-shoot.
|
|
83
83
|
// Values constructed at runtime so the source file doesn't trip
|
|
84
84
|
// GitHub Push Protection — same pattern as
|
|
85
|
-
// secret-detect-
|
|
85
|
+
// secret-detect-sanctum.test.ts:18 (CLAUDE.md § Secrets in tests).
|
|
86
86
|
const fakeApiKey = `sk-ant-${"a1b2c3d4".repeat(4)}XYZ987`; // sk-ant- + 32 chars
|
|
87
87
|
const fakeBearer = `${"abc123".repeat(8)}.${"def456".repeat(4)}`;
|
|
88
88
|
const fakeRandom = `${"x9zM4kP3qR7sT2vW".repeat(2)}`; // 32 chars, high entropy
|