switchroom 0.19.34 β 0.19.36
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/auth-broker/index.js +7 -1
- package/dist/cli/skill-validate-pretool.mjs +15 -2
- package/dist/cli/switchroom.js +1005 -482
- package/dist/host-control/main.js +156 -4
- package/dist/vault/approvals/kernel-server.js +7 -1
- package/dist/vault/broker/server.js +7 -1
- package/package.json +4 -2
- package/profiles/_base/start.sh.hbs +37 -12
- package/telegram-plugin/dist/gateway/gateway.js +476 -116
- package/telegram-plugin/format.ts +70 -15
- package/telegram-plugin/gateway/gateway.ts +27 -31
- package/telegram-plugin/gateway/ipc-server.ts +18 -15
- package/telegram-plugin/gateway/model-command.ts +279 -23
- package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
- package/telegram-plugin/operator-events.ts +40 -16
- package/telegram-plugin/secret-detect/db-uri.ts +90 -0
- package/telegram-plugin/secret-detect/index.ts +24 -1
- package/telegram-plugin/secret-detect/inert-values.ts +147 -0
- package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
- package/telegram-plugin/secret-detect/patterns.ts +24 -4
- package/telegram-plugin/tests/format-consistency.test.ts +93 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
- package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
- package/telegram-plugin/tests/model-command.test.ts +415 -4
- package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
- package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
- package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
- package/vendor/hindsight-memory/scripts/subagent_retain.py +103 -7
- package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +377 -0
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type DiscoverResult,
|
|
34
34
|
type ModelPickerOption,
|
|
35
35
|
} from '../../src/agents/model-picker.js'
|
|
36
|
+
import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.js'
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Aliases the claude CLI resolves natively (`claude --help`: "an alias for
|
|
@@ -50,6 +51,34 @@ import {
|
|
|
50
51
|
*/
|
|
51
52
|
export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Short SWITCHROOM-side spellings for pinned **Anthropic** model ids. These are
|
|
56
|
+
* expanded to the full `claude-*` id on the `/model` path BEFORE any
|
|
57
|
+
* Claude-vs-external classification runs, so what reaches the `.session-model`
|
|
58
|
+
* carrier (and therefore `claude --model`) is always the canonical id β the CLI
|
|
59
|
+
* never sees the short spelling.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately NOT `SR_MODEL_ALIASES`: that map means "external / OpenRouter /
|
|
62
|
+
* the Anthropic OAuth header is NOT forwarded". These targets are Anthropic
|
|
63
|
+
* OAuth-passthrough models, so putting them there would flip the
|
|
64
|
+
* header-forwarding + external-billing classification and surface them under the
|
|
65
|
+
* "π External models" keyboard page with an `srFriendlyLabel`.
|
|
66
|
+
*
|
|
67
|
+
* Also NOT `MODEL_ALIASES`: that list is the aliases the claude CLI resolves
|
|
68
|
+
* *itself*, and its members double as FAMILY tokens in `modelFamilyToken` /
|
|
69
|
+
* `canonicalClaudeToken` / `servedModelMatchesRequested`. A pinned-id shortcut
|
|
70
|
+
* is neither.
|
|
71
|
+
*
|
|
72
|
+
* NB the repo prefers family aliases over pinned ids (an alias tracks the
|
|
73
|
+
* current flagship, a pinned id goes stale) β these exist because an operator
|
|
74
|
+
* sometimes wants a specific pinned Opus, and typing `claude-opus-4-8` on a
|
|
75
|
+
* phone is hostile. Keys are matched case-insensitively.
|
|
76
|
+
*/
|
|
77
|
+
export const CLAUDE_MODEL_ALIASES: Record<string, string> = {
|
|
78
|
+
opus48: 'claude-opus-4-8',
|
|
79
|
+
'opus-4-8': 'claude-opus-4-8',
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
/**
|
|
54
83
|
* Shape gate for the model argument. This string is typed literally
|
|
55
84
|
* into the agent's tmux pane, so the gate is strict by construction:
|
|
@@ -67,6 +96,40 @@ export function isValidModelArg(arg: string): boolean {
|
|
|
67
96
|
return MODEL_ARG_RE.test(arg)
|
|
68
97
|
}
|
|
69
98
|
|
|
99
|
+
export const DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS = 180_000
|
|
100
|
+
export const SESSION_MODEL_RESOLUTION_POLL_MS = 250
|
|
101
|
+
|
|
102
|
+
/** Parse the bounded boot-resolution wait without allowing NaN/negative hangs. */
|
|
103
|
+
export function resolveSessionModelResolutionTimeoutMs(raw: string | undefined): number {
|
|
104
|
+
if (raw == null || raw.trim() === '') return DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS
|
|
105
|
+
const value = Number(raw)
|
|
106
|
+
return Number.isFinite(value) && value >= 0
|
|
107
|
+
? value
|
|
108
|
+
: DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Asynchronously wait until start.sh atomically publishes this boot's resolved
|
|
113
|
+
* session-model outputs. The deadline is checked on every iteration, including
|
|
114
|
+
* timeout=0, so a missing barrier can never hang gateway startup indefinitely.
|
|
115
|
+
*/
|
|
116
|
+
export async function waitForSessionModelResolution(input: {
|
|
117
|
+
barrierExists: () => boolean
|
|
118
|
+
timeoutMs: number
|
|
119
|
+
pollMs?: number
|
|
120
|
+
sleep?: (ms: number) => Promise<void>
|
|
121
|
+
}): Promise<boolean> {
|
|
122
|
+
const pollMs = input.pollMs ?? SESSION_MODEL_RESOLUTION_POLL_MS
|
|
123
|
+
const sleep = input.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
|
124
|
+
const startedAt = Date.now()
|
|
125
|
+
for (;;) {
|
|
126
|
+
if (input.barrierExists()) return true
|
|
127
|
+
const remaining = input.timeoutMs - (Date.now() - startedAt)
|
|
128
|
+
if (remaining <= 0) return false
|
|
129
|
+
await sleep(Math.min(pollMs, remaining))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
70
133
|
/** True when `name` is an sr-* (LiteLLM/OpenRouter) model identifier. */
|
|
71
134
|
export function isSrModel(name: string): boolean {
|
|
72
135
|
return name.startsWith('sr-')
|
|
@@ -76,11 +139,15 @@ export function isSrModel(name: string): boolean {
|
|
|
76
139
|
* True when `name` is a Claude model β either a well-known alias or a
|
|
77
140
|
* full `claude-*` id (including `[1m]` variants). This is intentionally
|
|
78
141
|
* broader than MODEL_ALIASES: any `claude-β¦` string from claude's own
|
|
79
|
-
* picker (e.g. `claude-opus-4-8`) qualifies
|
|
142
|
+
* picker (e.g. `claude-opus-4-8`) qualifies, as does a switchroom-side short
|
|
143
|
+
* spelling of a pinned Claude id (CLAUDE_MODEL_ALIASES, e.g. `opus48`) β those
|
|
144
|
+
* name an Anthropic OAuth-passthrough model, so a classification consumer must
|
|
145
|
+
* never mistake one for an external/OpenRouter route.
|
|
80
146
|
*/
|
|
81
147
|
export function isClaudeModel(name: string): boolean {
|
|
82
148
|
const lower = name.toLowerCase()
|
|
83
149
|
if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
|
|
150
|
+
if (lower in CLAUDE_MODEL_ALIASES) return true
|
|
84
151
|
return lower.startsWith('claude-')
|
|
85
152
|
}
|
|
86
153
|
|
|
@@ -621,7 +688,7 @@ export function planModelCommand(
|
|
|
621
688
|
): ModelCommandDisposition {
|
|
622
689
|
if (parsed.kind === 'show' && ctx.menuEnabled) return { kind: 'menu' }
|
|
623
690
|
if (parsed.kind === 'set' && isModelCommandBusy(ctx)) {
|
|
624
|
-
return { kind: 'queue', target:
|
|
691
|
+
return { kind: 'queue', target: expandModelAlias(parsed.model) }
|
|
625
692
|
}
|
|
626
693
|
return { kind: 'apply', parsed }
|
|
627
694
|
}
|
|
@@ -662,6 +729,24 @@ export interface ModelCommandDeps {
|
|
|
662
729
|
* rendered as "default".
|
|
663
730
|
*/
|
|
664
731
|
getConfiguredModel: () => string | null
|
|
732
|
+
/**
|
|
733
|
+
* The agent's cascade-resolved `thinking_effort` from `switchroom agent list`
|
|
734
|
+
* β the value start.sh bakes into `--effort` on the relaunch this command is
|
|
735
|
+
* about to schedule. Null when unset / unreadable (treated as the scaffold
|
|
736
|
+
* floor `low`, i.e. safe).
|
|
737
|
+
*
|
|
738
|
+
* Needed because a `/model` switch is SESSION-scoped and therefore invisible
|
|
739
|
+
* to `switchroom doctor`, whose `thinking_effort Γ adaptive model` check
|
|
740
|
+
* (`src/cli/doctor.ts:572`) reads the CONFIGURED `model:` out of
|
|
741
|
+
* switchroom.yaml. Switching the live session onto a pinned Opus 4.x id β by
|
|
742
|
+
* typing it, by a `CLAUDE_MODEL_ALIASES` shortcut, or by tapping the
|
|
743
|
+
* "Opus 4.8" button β lands the exact (pinned Opus 4.x, effort > low)
|
|
744
|
+
* combination doctor exists to warn about, with doctor none the wiser. So the
|
|
745
|
+
* assessment is re-run HERE, at switch time, against the effort the relaunch
|
|
746
|
+
* will actually use. A relaunch writes no `.session-effort` carrier, so any
|
|
747
|
+
* live `/effort` override is shed and the configured value is the right input.
|
|
748
|
+
*/
|
|
749
|
+
getConfiguredEffort: () => string | null
|
|
665
750
|
escapeHtml: (s: string) => string
|
|
666
751
|
preBlock: (s: string) => string
|
|
667
752
|
/**
|
|
@@ -721,14 +806,34 @@ export interface ModelCommandReply {
|
|
|
721
806
|
const PERSIST_NOTE =
|
|
722
807
|
'_A `/model` switch relaunches the session (~30s) on the chosen model. Session-only β reverts to the configured \`model:\` on the next restart. \`/model default\` reverts now. Live scrollback is replaced by a fresh session; memory and the handoff briefing carry the context. To change the default permanently, set \`model:\` in switchroom.yaml._'
|
|
723
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Discoverability line for CLAUDE_MODEL_ALIASES, grouped by target so several
|
|
811
|
+
* spellings of one id read as one entry (`opus48` Β· `opus-4-8` β `claude-opus-4-8`).
|
|
812
|
+
* Derived from the map, never hand-listed, so a new shortcut shows up in the
|
|
813
|
+
* help and dashboard text for free.
|
|
814
|
+
*/
|
|
815
|
+
function claudeAliasHints(prefix: string): string {
|
|
816
|
+
const byTarget = new Map<string, string[]>()
|
|
817
|
+
for (const [alias, target] of Object.entries(CLAUDE_MODEL_ALIASES)) {
|
|
818
|
+
const spellings = byTarget.get(target) ?? []
|
|
819
|
+
spellings.push(alias)
|
|
820
|
+
byTarget.set(target, spellings)
|
|
821
|
+
}
|
|
822
|
+
return [...byTarget]
|
|
823
|
+
.map(([target, spellings]) => `${spellings.map(a => `\`${prefix}${a}\``).join(' Β· ')} β \`${target}\``)
|
|
824
|
+
.join('; ')
|
|
825
|
+
}
|
|
826
|
+
|
|
724
827
|
function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
|
|
725
828
|
const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' Β· ')
|
|
829
|
+
const claudeAliasExamples = claudeAliasHints('')
|
|
726
830
|
const lines: string[] = []
|
|
727
831
|
if (reason) lines.push(`β οΈ ${deps.escapeHtml(reason)}`)
|
|
728
832
|
lines.push(
|
|
729
833
|
'**/model** β show or switch the Claude model',
|
|
730
834
|
'\`/model\` β show the configured model',
|
|
731
835
|
`\`/model <name>\` β switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' Β· ')} or a full model id)`,
|
|
836
|
+
`_Pinned Claude shortcuts:_ ${claudeAliasExamples}`,
|
|
732
837
|
`_OpenRouter shortcuts:_ ${srAliasExamples}`,
|
|
733
838
|
'_Every switch relaunches the session (~30s) on the chosen model β Claude and OpenRouter (sr-\\*) alike._',
|
|
734
839
|
PERSIST_NOTE,
|
|
@@ -751,6 +856,7 @@ export async function handleModelCommand(
|
|
|
751
856
|
`**Model β ${deps.escapeHtml(deps.getAgentName())}**`,
|
|
752
857
|
`Configured: \`${deps.escapeHtml(shown)}\``,
|
|
753
858
|
`Switch the live session: ${MODEL_ALIASES.map(a => `\`/model ${a}\``).join(' Β· ')}`,
|
|
859
|
+
`Pinned Claude shortcuts: ${claudeAliasHints('/model ')}`,
|
|
754
860
|
`OpenRouter shortcuts: ${srAliasExamples}`,
|
|
755
861
|
'or \`/model <full-model-id>\`',
|
|
756
862
|
PERSIST_NOTE,
|
|
@@ -765,8 +871,9 @@ export async function handleModelCommand(
|
|
|
765
871
|
return helpText(deps, `not a valid model name: ${parsed.model}`)
|
|
766
872
|
}
|
|
767
873
|
|
|
768
|
-
// Expand short aliases
|
|
769
|
-
|
|
874
|
+
// Expand short aliases BEFORE any Claude-vs-external decision below:
|
|
875
|
+
// `opus48`/`opus-4-8` β `claude-opus-4-8`, `flash` β `sr-gemini-2.5-flash`, etc.
|
|
876
|
+
const model = expandModelAlias(parsed.model)
|
|
770
877
|
|
|
771
878
|
// Busy gate: a switch RELAUNCHES the session, which is unsafe mid-turn (it
|
|
772
879
|
// would tear down the live turn). The gateway's mid-turn path ACKs + queues +
|
|
@@ -822,16 +929,70 @@ function relaunchErrorReply(
|
|
|
822
929
|
* silently serves the fallback. Say so IMMEDIATELY in the switch ack, and
|
|
823
930
|
* point at the first-reply tripwire that will catch it. Aliases and sr-* ids
|
|
824
931
|
* are vouched by their own gates (MODEL_ALIASES / the LiteLLM route probe),
|
|
825
|
-
* so only typed `claude-*` ids carry the caveat
|
|
932
|
+
* so only typed `claude-*` ids carry the caveat β and a CLAUDE_MODEL_ALIASES
|
|
933
|
+
* TARGET is vouched by the same curation gate that makes it offline-trustable
|
|
934
|
+
* (isOfflineTrustedModelToken), so it is exempt too; warning "can't be
|
|
935
|
+
* validated" on a shortcut switchroom itself curates would contradict that.
|
|
936
|
+
*
|
|
937
|
+
* The exemption compares `canonicalModelToken(model)` β the SAME form
|
|
938
|
+
* `expandModelAlias` emits β so the token that reaches `claude --model` and the
|
|
939
|
+
* token this gate judges can never disagree. Comparing a lowercased form against
|
|
940
|
+
* a verbatim-emitted one is exactly how `/model CLAUDE-OPUS-4-8` used to launch
|
|
941
|
+
* an uncanonical id under a clean green ack.
|
|
942
|
+
*
|
|
943
|
+
* What the exemption costs, stated honestly: the ONLY remaining signal that an
|
|
944
|
+
* exempted target silently fell back is the first-reply divergence tripwire
|
|
945
|
+
* (`servedModelMatchesRequested`). The post-boot confirmation card is NOT a
|
|
946
|
+
* second signal for a pinned id whose FAMILY equals the configured default's:
|
|
947
|
+
* `modelFamilyToken('claude-opus-4-8')` is `'opus'`, so on an `opus`-default
|
|
948
|
+
* agent `classifyModelSwitchConfirmation` returns `default`, not `not-applied`
|
|
949
|
+
* (see #4005 β a pre-existing property of the family reduction, reachable
|
|
950
|
+
* before this map existed by typing the full id).
|
|
951
|
+
* Exported for tests.
|
|
826
952
|
*/
|
|
827
953
|
export function unvalidatedIdCaveat(
|
|
828
954
|
deps: Pick<ModelCommandDeps, 'escapeHtml'>,
|
|
829
955
|
model: string,
|
|
830
956
|
): string | null {
|
|
831
|
-
|
|
957
|
+
const lower = canonicalModelToken(model).toLowerCase()
|
|
958
|
+
if (!lower.startsWith('claude-')) return null
|
|
959
|
+
if (Object.values(CLAUDE_MODEL_ALIASES).includes(lower)) return null
|
|
832
960
|
return `_\`${deps.escapeHtml(model)}\` can't be validated before launch β if it isn't a real Claude model id, claude will silently serve the configured fallback model instead. I check the first reply and will warn if that happens._`
|
|
833
961
|
}
|
|
834
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Switch-time surfacing of the #1978 adaptive-thinking risk (`thinking_effort`
|
|
965
|
+
* above `low` on a PINNED Opus 4.x id).
|
|
966
|
+
*
|
|
967
|
+
* `assessThinkingEffortRisk` had exactly ONE consumer β `switchroom doctor`
|
|
968
|
+
* (`src/cli/doctor.ts:572`) β which reads the CONFIGURED `model:` from
|
|
969
|
+
* switchroom.yaml. A `/model` switch is session-scoped and never touches that
|
|
970
|
+
* file, so every route onto a pinned Opus 4.x (typed full id, a
|
|
971
|
+
* `CLAUDE_MODEL_ALIASES` shortcut, or the one-tap "Opus 4.8" button) reached the
|
|
972
|
+
* risky combination with no warning anywhere. This is the second consumer, on
|
|
973
|
+
* the path that actually creates the combination.
|
|
974
|
+
*
|
|
975
|
+
* Advisory only β it never rewrites anyone's effort. Returns null when the combo
|
|
976
|
+
* is safe (effort unset/`low`, or any non-Opus-4.x model, including Opus 5 and
|
|
977
|
+
* the bare `opus` alias).
|
|
978
|
+
*/
|
|
979
|
+
export function thinkingEffortCaveat(
|
|
980
|
+
deps: Pick<ModelCommandDeps, 'escapeHtml' | 'getConfiguredEffort'>,
|
|
981
|
+
model: string,
|
|
982
|
+
): string | null {
|
|
983
|
+
let effort: string | null
|
|
984
|
+
try {
|
|
985
|
+
effort = deps.getConfiguredEffort()
|
|
986
|
+
} catch {
|
|
987
|
+
// The effort probe shells out to `switchroom agent list`; a transient
|
|
988
|
+
// failure must never block or fail the switch it is only annotating.
|
|
989
|
+
return null
|
|
990
|
+
}
|
|
991
|
+
const risk = assessThinkingEffortRisk(model, effort ?? undefined)
|
|
992
|
+
if (!risk.risky || !risk.reason) return null
|
|
993
|
+
return `β οΈ _${deps.escapeHtml(risk.reason)}_`
|
|
994
|
+
}
|
|
995
|
+
|
|
835
996
|
/** Schedule a carrier relaunch onto `model`, returning the deterministic ack. */
|
|
836
997
|
async function scheduleRelaunchReply(
|
|
837
998
|
deps: ModelCommandDeps,
|
|
@@ -844,8 +1005,14 @@ async function scheduleRelaunchReply(
|
|
|
844
1005
|
return relaunchErrorReply(deps, model, err)
|
|
845
1006
|
}
|
|
846
1007
|
const caveat = unvalidatedIdCaveat(deps, model)
|
|
1008
|
+
const effortRisk = thinkingEffortCaveat(deps, model)
|
|
847
1009
|
return {
|
|
848
|
-
text: [
|
|
1010
|
+
text: [
|
|
1011
|
+
switchingLine(deps, model),
|
|
1012
|
+
...(caveat ? [caveat] : []),
|
|
1013
|
+
...(effortRisk ? [effortRisk] : []),
|
|
1014
|
+
PERSIST_NOTE,
|
|
1015
|
+
].join('\n'),
|
|
849
1016
|
html: true,
|
|
850
1017
|
}
|
|
851
1018
|
}
|
|
@@ -934,15 +1101,28 @@ export const MODEL_CALLBACK_PAGE_MAIN = 'mdl:page:main'
|
|
|
934
1101
|
export type ModelMenuPage = 'main' | 'external'
|
|
935
1102
|
|
|
936
1103
|
/**
|
|
937
|
-
* Static Claude
|
|
938
|
-
* own `/model` picker (deps.discover)
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
1104
|
+
* Static Claude `--model` TOKENS appended to the scraped Claude group β extra
|
|
1105
|
+
* keyboard rows for targets the claude CLI's own `/model` picker (deps.discover)
|
|
1106
|
+
* does not list. Each renders as a button that switches via the carrier relaunch
|
|
1107
|
+
* (MODEL_CALLBACK_ALIAS β scheduleModelRelaunch, rev 5).
|
|
1108
|
+
*
|
|
1109
|
+
* NB "token", not "alias": members are not required to be CLI-resolvable aliases
|
|
1110
|
+
* and are treated opaquely by both call sites (they are only concatenated onto
|
|
1111
|
+
* the `mdl:alias:` callback prefix, whose wire format predates this list). The
|
|
1112
|
+
* two kinds present today:
|
|
1113
|
+
* - `fable` β a genuine alias the CLI resolves itself; the picker just omits
|
|
1114
|
+
* it. Boots via the LiteLLM router repoint in start.sh (see the fable case).
|
|
1115
|
+
* - `claude-opus-4-8` β a full PINNED id, not an alias. Rendered because the
|
|
1116
|
+
* picker doesn't offer it and typing it on a phone is hostile.
|
|
1117
|
+
* Extend with either kind; a member only has to be a legal `claude --model` arg.
|
|
943
1118
|
*/
|
|
944
|
-
export const
|
|
945
|
-
{
|
|
1119
|
+
export const EXTRA_CLAUDE_MENU_TOKENS: ReadonlyArray<{ token: string; label: string }> = [
|
|
1120
|
+
{ token: 'fable', label: 'Fable' },
|
|
1121
|
+
// Pinned Opus 4.8. The button carries the CANONICAL id (not the `opus48`
|
|
1122
|
+
// shortcut) so the tap needs no expansion to be recognized by an older
|
|
1123
|
+
// gateway, and so `canonicalClaudeToken` resolves it directly. Typed
|
|
1124
|
+
// `/model opus48` / `/model opus-4-8` land on the same target.
|
|
1125
|
+
{ token: 'claude-opus-4-8', label: 'Opus 4.8' },
|
|
946
1126
|
]
|
|
947
1127
|
|
|
948
1128
|
/**
|
|
@@ -1039,13 +1219,69 @@ export function isOfflineTrustedModelToken(token: string): boolean {
|
|
|
1039
1219
|
const lower = token.toLowerCase()
|
|
1040
1220
|
if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
|
|
1041
1221
|
if (lower in SR_MODEL_ALIASES) return true
|
|
1042
|
-
|
|
1222
|
+
if (Object.values(SR_MODEL_ALIASES).includes(lower)) return true
|
|
1223
|
+
// Curated pinned-Claude shortcuts and their targets. Both spellings are
|
|
1224
|
+
// trustable for the same reason the sr-* alias TARGETS are: the id is
|
|
1225
|
+
// switchroom-curated, not a hand-typed guess. A queue that already expanded
|
|
1226
|
+
// (planModelCommand) persists the VALUE, an alias tap persists the KEY β so
|
|
1227
|
+
// both sides must be accepted or `/model opus48` would survive a boot only
|
|
1228
|
+
// on one of the two paths.
|
|
1229
|
+
if (lower in CLAUDE_MODEL_ALIASES) return true
|
|
1230
|
+
return Object.values(CLAUDE_MODEL_ALIASES).includes(lower)
|
|
1043
1231
|
}
|
|
1044
1232
|
|
|
1045
1233
|
export function expandSrAlias(arg: string): string {
|
|
1046
1234
|
return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
|
|
1047
1235
|
}
|
|
1048
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* The ONE canonical form of a model token β what `claude --model` is handed and
|
|
1239
|
+
* what every downstream gate compares against.
|
|
1240
|
+
*
|
|
1241
|
+
* Two normalizations, both narrow:
|
|
1242
|
+
* - **trim** β unconditional. `unvalidatedIdCaveat` already trimmed while
|
|
1243
|
+
* expansion did not, so the two disagreed on a padded token.
|
|
1244
|
+
* - **lowercase, `claude-*` ONLY.** Every real Anthropic model id is lowercase
|
|
1245
|
+
* and a phone autocapitalizes, so `/model Claude-Opus-4-8` used to be launched
|
|
1246
|
+
* VERBATIM as an unvalidated id while the caveat exemption (which lowercases)
|
|
1247
|
+
* suppressed the warning β a clean green ack for a token the caveat was
|
|
1248
|
+
* written for. Non-`claude-` tokens are left alone: `sr-*` ids are matched
|
|
1249
|
+
* case-insensitively by their own maps, and rewriting an arbitrary external
|
|
1250
|
+
* id's case is not ours to do.
|
|
1251
|
+
*
|
|
1252
|
+
* Applied by `expandModelAlias`, so canonicalization happens on every `/model`
|
|
1253
|
+
* path (typed apply, mid-turn queue, menu tap, queued-across-restart persist)
|
|
1254
|
+
* whether or not the token hit a shortcut map β one canonical form flows to
|
|
1255
|
+
* `claude --model`, and `unvalidatedIdCaveat` sees exactly that form.
|
|
1256
|
+
*/
|
|
1257
|
+
export function canonicalModelToken(arg: string): string {
|
|
1258
|
+
const trimmed = arg.trim()
|
|
1259
|
+
const lower = trimmed.toLowerCase()
|
|
1260
|
+
return lower.startsWith('claude-') ? lower : trimmed
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Expand a short pinned-Claude spelling (case-insensitive) to its full `claude-*` id. */
|
|
1264
|
+
export function expandClaudeAlias(arg: string): string {
|
|
1265
|
+
return CLAUDE_MODEL_ALIASES[arg.trim().toLowerCase()] ?? arg
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* The ONE expansion every `/model` argument goes through, on every path that
|
|
1270
|
+
* consumes a user-supplied token (typed apply, mid-turn queue, menu tap,
|
|
1271
|
+
* queued-across-restart persist). Claude shortcuts resolve first, then sr-*
|
|
1272
|
+
* shortcuts; the two key sets are disjoint by construction (a Claude shortcut
|
|
1273
|
+
* never expands to an `sr-*` id and vice versa), so order only documents intent.
|
|
1274
|
+
* The result is `canonicalModelToken`-normalized, so a miss can no longer emit a
|
|
1275
|
+
* verbatim mixed-case `claude-*` id that the caveat exemption then mis-matches.
|
|
1276
|
+
*
|
|
1277
|
+
* Expanding HERE β before `isClaudeModel` / `isSrModel` / `externalModelNames`
|
|
1278
|
+
* ever see the token β is what keeps a pinned-Claude shortcut on the Anthropic
|
|
1279
|
+
* OAuth passthrough instead of being misread as an external route.
|
|
1280
|
+
*/
|
|
1281
|
+
export function expandModelAlias(arg: string): string {
|
|
1282
|
+
return canonicalModelToken(expandSrAlias(expandClaudeAlias(arg)))
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1049
1285
|
export function srFriendlyLabel(srName: string): string {
|
|
1050
1286
|
return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
|
|
1051
1287
|
}
|
|
@@ -1161,6 +1397,13 @@ function busyStaticMenu(
|
|
|
1161
1397
|
callback_data: `${MODEL_CALLBACK_ALIAS}${alias}`,
|
|
1162
1398
|
}])
|
|
1163
1399
|
}
|
|
1400
|
+
// Same extra Claude rows the live keyboard renders (Fable, pinned Opus 4.8),
|
|
1401
|
+
// minus any already covered by a MODEL_ALIASES row above β otherwise a model
|
|
1402
|
+
// is selectable when idle but vanishes from the mid-turn quick list.
|
|
1403
|
+
for (const { token, label } of EXTRA_CLAUDE_MENU_TOKENS) {
|
|
1404
|
+
if ((MODEL_ALIASES as readonly string[]).includes(token.toLowerCase())) continue
|
|
1405
|
+
rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${token}` }])
|
|
1406
|
+
}
|
|
1164
1407
|
rows.push([{ text: 'Default (configured)', callback_data: `${MODEL_CALLBACK_ALIAS}default` }])
|
|
1165
1408
|
if (externalNames.length > 0) {
|
|
1166
1409
|
rows.push([{ text: 'π External models βΈ', callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }])
|
|
@@ -1230,16 +1473,16 @@ function mainPageKeyboard(
|
|
|
1230
1473
|
}])
|
|
1231
1474
|
}
|
|
1232
1475
|
|
|
1233
|
-
// Static Claude
|
|
1234
|
-
// scraped Claude options already include a matching row,
|
|
1235
|
-
// static one too.
|
|
1236
|
-
for (const {
|
|
1476
|
+
// Static Claude tokens the CLI picker omits (Fable, pinned Opus 4.8).
|
|
1477
|
+
// Deduped: if the scraped Claude options already include a matching row,
|
|
1478
|
+
// don't render the static one too.
|
|
1479
|
+
for (const { token, label } of EXTRA_CLAUDE_MENU_TOKENS) {
|
|
1237
1480
|
const already = claudeOptions.some(
|
|
1238
1481
|
(o) => o.label.toLowerCase() === label.toLowerCase() ||
|
|
1239
|
-
o.label.toLowerCase() ===
|
|
1482
|
+
o.label.toLowerCase() === token.toLowerCase(),
|
|
1240
1483
|
)
|
|
1241
1484
|
if (already) continue
|
|
1242
|
-
rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${
|
|
1485
|
+
rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${token}` }])
|
|
1243
1486
|
}
|
|
1244
1487
|
|
|
1245
1488
|
if (hasExternal) {
|
|
@@ -1434,9 +1677,17 @@ export async function handleModelMenuCallback(
|
|
|
1434
1677
|
token = data.slice(MODEL_CALLBACK_SELECT.length)
|
|
1435
1678
|
label = token
|
|
1436
1679
|
}
|
|
1680
|
+
// Shape gate FIRST, on the RAW callback payload. `expandModelAlias` trims,
|
|
1681
|
+
// so gating after it would let a whitespace-padded payload through a regex
|
|
1682
|
+
// that exists precisely to guarantee one whitespace-free token reaches the
|
|
1683
|
+
// tmux pane. Order: shape β expand β recognition.
|
|
1437
1684
|
if (!isValidModelArg(token)) {
|
|
1438
1685
|
return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
|
|
1439
1686
|
}
|
|
1687
|
+
// Same expansion the typed path applies, before the recognition gate: a
|
|
1688
|
+
// callback minted by an older gateway (or a hand-crafted `mdl:alias:opus48`)
|
|
1689
|
+
// must resolve to the canonical id rather than be rejected as unrecognized.
|
|
1690
|
+
token = expandModelAlias(token)
|
|
1440
1691
|
// N1: reject a token we don't recognize (a stale OLD-gateway `mdl:s:<hex>`
|
|
1441
1692
|
// callback, an unmapped SELECT row, or garbage). Relaunching onto it would
|
|
1442
1693
|
// write a carrier claude silently falls back from (--fallback-model). Re-render
|
|
@@ -1498,11 +1749,16 @@ async function menuRelaunchOutcome(
|
|
|
1498
1749
|
}
|
|
1499
1750
|
}
|
|
1500
1751
|
const friendly = isDefault ? 'the configured default' : label
|
|
1752
|
+
// Same #1978 switch-time guard the typed path carries β the one-tap
|
|
1753
|
+
// "Opus 4.8" button is the EASIEST route into (pinned Opus 4.x, effort > low)
|
|
1754
|
+
// and doctor cannot see a session-scoped switch. `/model default` reverts to
|
|
1755
|
+
// the configured model, which doctor already covers, so it is not assessed.
|
|
1756
|
+
const effortRisk = isDefault ? null : thinkingEffortCaveat(deps, token)
|
|
1501
1757
|
return {
|
|
1502
1758
|
answer: `Switching to ${isDefault ? 'default' : label} β relaunching (~30s)`,
|
|
1503
1759
|
reply: await menuWithBannerStatic(
|
|
1504
1760
|
deps,
|
|
1505
|
-
`π Switching session to **${deps.escapeHtml(friendly)}** β relaunching (~30s)
|
|
1761
|
+
`π Switching session to **${deps.escapeHtml(friendly)}** β relaunching (~30s).${effortRisk ? `\n${effortRisk}` : ''}\n${PERSIST_NOTE}`,
|
|
1506
1762
|
),
|
|
1507
1763
|
}
|
|
1508
1764
|
}
|
|
@@ -191,7 +191,18 @@ export function normalizeOutboundBody(
|
|
|
191
191
|
if (!literalText) text = normalizeParagraphBreaks(text)
|
|
192
192
|
text = redact(text, site)
|
|
193
193
|
if (!literalText) {
|
|
194
|
-
let formatted = stripExcessBold(normalizePunctuation(text))
|
|
194
|
+
let formatted = stripExcessBold(normalizePunctuation(text), (d) => {
|
|
195
|
+
// Observability: the over-bold tripwire silently flattened formatting.
|
|
196
|
+
// Emit one diagnostic line naming the rule + measured ratio so a lost
|
|
197
|
+
// reply is traceable (it previously vanished with no signal).
|
|
198
|
+
try {
|
|
199
|
+
process.stderr.write(
|
|
200
|
+
`telegram gateway: strip-excess-bold: fired site=${site} rule=${d.rule} ratio=${d.ratio.toFixed(3)}\n`,
|
|
201
|
+
)
|
|
202
|
+
} catch {
|
|
203
|
+
// stderr write must never break the send path. Swallow.
|
|
204
|
+
}
|
|
205
|
+
})
|
|
195
206
|
if (addSpacers) formatted = addParagraphSpacers(formatted)
|
|
196
207
|
text = formatted
|
|
197
208
|
// Temporal normalization (#3501): UTC/Zulu β local wall clock, then
|
|
@@ -23,11 +23,32 @@ import {
|
|
|
23
23
|
|
|
24
24
|
// βββ Taxonomy ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The CANONICAL runtime list of every operator-event kind β the SINGLE source
|
|
28
|
+
* of truth for the taxonomy. The `OperatorEventKind` type is derived from it
|
|
29
|
+
* (below), and every other place that needs the set at RUNTIME (notably the
|
|
30
|
+
* gateway IPC validator's `VALID_OPERATOR_KINDS` in `gateway/ipc-server.ts`)
|
|
31
|
+
* imports THIS array rather than re-declaring a hand-maintained literal.
|
|
32
|
+
*
|
|
33
|
+
* WHY THIS IS AN ARRAY, NOT JUST A TYPE (the drift this closes)
|
|
34
|
+
* ------------------------------------------------------------
|
|
35
|
+
* A TypeScript union erases at compile time, so a runtime allowlist could only
|
|
36
|
+
* mirror it by hand β and it drifted: `ipc-server.ts` shipped a 9-entry Set
|
|
37
|
+
* that never gained `provider-credit-exhausted` / `mcp-dependency-blocked` /
|
|
38
|
+
* `proxy-misconfig` when those kinds were added here. The bridge forwarded a
|
|
39
|
+
* `provider-credit-exhausted` operator_event; the gateway validator rejected it
|
|
40
|
+
* as an "invalid IPC message shape" and DROPPED it β so a real OpenRouter/
|
|
41
|
+
* LiteLLM 402 credit wall produced NO loud Telegram card at all (the 2026-07-30
|
|
42
|
+
* incident). Deriving both the type and the runtime allowlist from this one
|
|
43
|
+
* array makes that class of silent drop structurally impossible.
|
|
44
|
+
*
|
|
45
|
+
* Order is not significant. Adding a kind is a one-line edit HERE.
|
|
46
|
+
*/
|
|
47
|
+
export const OPERATOR_EVENT_KINDS = [
|
|
48
|
+
'credentials-expired',
|
|
49
|
+
'credentials-invalid',
|
|
50
|
+
'proxy-misconfig',
|
|
51
|
+
'credit-exhausted',
|
|
31
52
|
/**
|
|
32
53
|
* A THIRD-PARTY model provider (OpenRouter / OpenAI / Perplexity) reports no
|
|
33
54
|
* credit remaining β HTTP 402 `payment_required`, "insufficient credits",
|
|
@@ -37,7 +58,7 @@ export type OperatorEventKind =
|
|
|
37
58
|
* must not share a card. Operator-actionable (top up in the vendor console),
|
|
38
59
|
* never user-actionable β see {@link OPERATOR_ACTIONABLE_KINDS}.
|
|
39
60
|
*/
|
|
40
|
-
|
|
61
|
+
'provider-credit-exhausted',
|
|
41
62
|
/**
|
|
42
63
|
* A paid MCP dependency (Perplexity, Eraser, Brevo, Postiz, Meta/Google Ads,
|
|
43
64
|
* Cloudflare β¦) is refusing work because its KEY is the problem β out of
|
|
@@ -49,16 +70,19 @@ export type OperatorEventKind =
|
|
|
49
70
|
* `mcp-credential-failure.ts`. Operator-actionable: only the operator can top
|
|
50
71
|
* up or re-issue a key.
|
|
51
72
|
*/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
'mcp-dependency-blocked',
|
|
74
|
+
'quota-exhausted',
|
|
75
|
+
'rate-limited',
|
|
76
|
+
'agent-crashed',
|
|
77
|
+
'agent-restarted-unexpectedly',
|
|
78
|
+
'unknown-4xx',
|
|
79
|
+
'unknown-5xx',
|
|
80
|
+
'config-warning',
|
|
81
|
+
'always-allow-persist-failed',
|
|
82
|
+
'mental-model-persist-failed',
|
|
83
|
+
] as const
|
|
84
|
+
|
|
85
|
+
export type OperatorEventKind = (typeof OPERATOR_EVENT_KINDS)[number]
|
|
62
86
|
|
|
63
87
|
export interface OperatorEvent {
|
|
64
88
|
kind: OperatorEventKind
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database / service connection-URI credential scanner.
|
|
3
|
+
*
|
|
4
|
+
* Gap closed (2026-07 hindsight write-path audit): a connection string
|
|
5
|
+
* like `postgres://appuser:<password>@db.internal:5432/prod` passed the
|
|
6
|
+
* whole detection stack untouched and was stored verbatim in agent
|
|
7
|
+
* memory. `url-redact.ts` only rewrites `http(s)`/`ws(s)`/`ftp` (its
|
|
8
|
+
* `URL_RE` is scheme-limited), and no pattern in `patterns.ts` describes
|
|
9
|
+
* the `scheme://user:pass@host` shape, so a production database password
|
|
10
|
+
* was invisible to every caller of `detectSecrets`.
|
|
11
|
+
*
|
|
12
|
+
* This scanner is deliberately SCHEME-AGNOSTIC β it matches any
|
|
13
|
+
* `<scheme>://<user>:<password>@<host>` β so postgres, mysql, mongodb,
|
|
14
|
+
* redis, amqp, clickhouse, mssql and whatever ships next are all covered
|
|
15
|
+
* without a scheme allowlist to keep current. Precision comes from the
|
|
16
|
+
* shape, not from the scheme list: userinfo-with-password in a URI is a
|
|
17
|
+
* credential by construction.
|
|
18
|
+
*
|
|
19
|
+
* Only the PASSWORD bytes are reported. The username is left intact: it
|
|
20
|
+
* is rarely the secret, and keeping it preserves the diagnostic value of
|
|
21
|
+
* the stored line ("which account", not just "some database").
|
|
22
|
+
*
|
|
23
|
+
* Confidence is `ambiguous`, NOT `high`. `pipeline.ts` auto-writes every
|
|
24
|
+
* high-confidence inbound hit to the vault and the caller DELETES the
|
|
25
|
+
* user's Telegram message; promoting this rule to `high` would change
|
|
26
|
+
* inbound gating as a side effect of a redaction fix. `redact()` masks
|
|
27
|
+
* ambiguous hits, which is what this change is for.
|
|
28
|
+
*/
|
|
29
|
+
import type { RawHit } from './kv-scanner.js'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `<scheme>://<user>:<password>@<host>`.
|
|
33
|
+
*
|
|
34
|
+
* - scheme: RFC 3986 shape, at least two chars so a Windows drive letter
|
|
35
|
+
* (`c://`) can't anchor a match.
|
|
36
|
+
* - user: no `/`, `@`, `:` or whitespace.
|
|
37
|
+
* - password: anything up to an authority TERMINATOR (`/`, `?`, `#` or
|
|
38
|
+
* whitespace) β `@` deliberately INCLUDED. Greedy matching then backs
|
|
39
|
+
* off to the LAST `@` in the authority, which is where the host
|
|
40
|
+
* actually starts.
|
|
41
|
+
*
|
|
42
|
+
* #3982 review, MAJOR 3: excluding `@` (so the match stopped at the
|
|
43
|
+
* FIRST one) looked conservative and was the opposite. An unencoded
|
|
44
|
+
* `@` in a pasted DSN is common, and
|
|
45
|
+
* `postgres://appuser:p@ssW0rd123@db.internal:5432/prod` masked one
|
|
46
|
+
* byte and stored the other ten in clear β
|
|
47
|
+
* `postgres://appuser:[REDACTED:db_uri_password]@ssW0rd123@db.internalβ¦`
|
|
48
|
+
* β while also telling a reader exactly how long the masked prefix
|
|
49
|
+
* was. `url-redact.ts` already did this right for http(s) by taking
|
|
50
|
+
* `lastIndexOf('@')` over the authority; this is the same rule for the
|
|
51
|
+
* non-HTTP schemes.
|
|
52
|
+
* - host: at least one non-delimiter byte, so `user:pass@` with nothing
|
|
53
|
+
* after it is not a connection string.
|
|
54
|
+
*/
|
|
55
|
+
const DB_URI_RE =
|
|
56
|
+
/\b([a-zA-Z][a-zA-Z0-9+.-]+):\/\/([^\s:/@]+):([^\s/?#]+)@([^\s/@?#]+)/g
|
|
57
|
+
|
|
58
|
+
export const DB_URI_RULE_ID = 'db_uri_password'
|
|
59
|
+
|
|
60
|
+
/** Values that are already masked or are obviously not a live credential. */
|
|
61
|
+
function isInertPassword(value: string): boolean {
|
|
62
|
+
if (value.startsWith('[REDACTED')) return true
|
|
63
|
+
// `redactUrls()` rewrites `user:pass@` to `***@` (empty password), but a
|
|
64
|
+
// hand-written `***`/`xxx` mask should not round-trip into a detection
|
|
65
|
+
// either.
|
|
66
|
+
return /^[*x]+$/i.test(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function scanDbUris(text: string): RawHit[] {
|
|
70
|
+
const hits: RawHit[] = []
|
|
71
|
+
DB_URI_RE.lastIndex = 0
|
|
72
|
+
let m: RegExpExecArray | null
|
|
73
|
+
while ((m = DB_URI_RE.exec(text)) !== null) {
|
|
74
|
+
const scheme = m[1]!
|
|
75
|
+
const user = m[2]!
|
|
76
|
+
const password = m[3]!
|
|
77
|
+
if (isInertPassword(password)) continue
|
|
78
|
+
// Offset of the password = match start + scheme + "://" + user + ":".
|
|
79
|
+
const start = m.index + scheme.length + 3 + user.length + 1
|
|
80
|
+
hits.push({
|
|
81
|
+
rule_id: DB_URI_RULE_ID,
|
|
82
|
+
start,
|
|
83
|
+
end: start + password.length,
|
|
84
|
+
matched_text: password,
|
|
85
|
+
key_name: `${scheme}_password`,
|
|
86
|
+
confidence: 'ambiguous',
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
return hits
|
|
90
|
+
}
|