@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12

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.
Files changed (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
@@ -0,0 +1,309 @@
1
+ import * as crypto from "node:crypto";
2
+ import type { SecretEntry } from "./obfuscator";
3
+ import { ensureDistinctReplacement, generateDeterministicReplacement, REPLACEMENT_CHARS } from "./replacement";
4
+
5
+ // ═══════════════════════════════════════════════════════════════════════════
6
+ // Placeholder format
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+
9
+ const HASH_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
10
+ // Base length is sized for ~62 bits of entropy (64 bits of a keyed digest
11
+ // rendered as 12 base36 chars) so unrelated secrets do not collide on a shared
12
+ // base. A collision would let a persisted placeholder deobfuscate to the wrong
13
+ // secret when the configured secret set or its ordering changes across sessions.
14
+ const HASH_LEN = 12;
15
+ const MAX_FRIENDLY_NAME_LEN = 32;
16
+ // Plain/regex obfuscate matches shorter than this are toned down (never placed
17
+ // behind a reversible placeholder) to avoid redacting small words/fragments.
18
+ export const MIN_OBFUSCATE_SECRET_LEN = 8;
19
+
20
+ // Per-process fallback key used when a caller does not supply a persisted
21
+ // per-install key. It is random (never shipped in source), so model-visible
22
+ // placeholders cannot be reversed by dictionary-hashing candidate secrets; it
23
+ // only forgoes cross-session token stability, which the persisted key provides.
24
+ let ephemeralPlaceholderKey: string | undefined;
25
+ export function defaultPlaceholderKey(): string {
26
+ ephemeralPlaceholderKey ??= crypto.randomBytes(32).toString("base64url");
27
+ return ephemeralPlaceholderKey;
28
+ }
29
+
30
+ type PlaceholderCaseHint = "U" | "L" | "C" | "M";
31
+
32
+ /** Normalize a friendly name into the model-visible placeholder prefix. */
33
+ export function sanitizeSecretFriendlyName(name: string): string | undefined {
34
+ const sanitized = name
35
+ .replace(/[^A-Za-z0-9]/g, "")
36
+ .toUpperCase()
37
+ .slice(0, MAX_FRIENDLY_NAME_LEN);
38
+ return sanitized.length > 0 ? sanitized : undefined;
39
+ }
40
+
41
+ /**
42
+ * Normalize a secret value into the same alnum-only, uppercased shape a
43
+ * friendly-name label or placeholder prefix is sanitized into, so comparing a
44
+ * raw (possibly lowercase/punctuated) secret value against already-sanitized,
45
+ * model-visible text does not miss a case- or separator-only variant. Unlike
46
+ * `sanitizeSecretFriendlyName` this never truncates and never signals "empty"
47
+ * via `undefined` — callers already guard on `.length > 0` before comparing.
48
+ */
49
+ export function sanitizeForCollisionCheck(value: string): string {
50
+ return value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
51
+ }
52
+
53
+ // A label leaks a secret either by containing the whole normalized secret or,
54
+ // once it reaches the public display cap, by being the secret's visible prefix.
55
+ // Shorter names like "TOKEN" can still be intentional generic labels.
56
+ export function sanitizedLabelCollidesWithSecret(sanitizedLabel: string, sanitizedSecret: string): boolean {
57
+ if (sanitizedSecret.length === 0) return false;
58
+ if (sanitizedLabel.includes(sanitizedSecret)) return true;
59
+ return sanitizedLabel.length >= MAX_FRIENDLY_NAME_LEN && sanitizedSecret.startsWith(sanitizedLabel);
60
+ }
61
+
62
+ /**
63
+ * Whether an entry needs the persisted placeholder key: either because it can
64
+ * produce a reversible (keyed) obfuscate-mode placeholder, or because a default
65
+ * (no custom `replacement`) replace-mode regex can reach
66
+ * `#generateRegexReplacement`'s key-derived idempotent fallback marker (see
67
+ * `#generateReplacement`) when every same-length candidate re-matches a
68
+ * pathological match-everything config (e.g. `[\s\S]{8}`). That fallback depends
69
+ * on the persisted per-install key — not just length — to stay a fixed point
70
+ * across a process restart; without a persisted key, a fresh install falls back
71
+ * to a process-random key (`defaultPlaceholderKey()`), so the fallback marker
72
+ * would churn across restarts even though the algorithm itself is stable. A
73
+ * regex WITH a custom `replacement` never reaches that fallback (it always emits
74
+ * the literal configured string), and a plain replace secret's replacement is
75
+ * pure content-hash (`#generateSecretReplacement`), so neither needs the key.
76
+ * Short plain obfuscate entries are toned down (never placeheld), so they must
77
+ * NOT force key creation: otherwise a `secret-placeholder.key` file is written
78
+ * and persisted for a config that ends up with no active secrets, leaving the
79
+ * key readable via a tool and reusable for later placeholders.
80
+ */
81
+ export function secretEntryNeedsPlaceholderKey(entry: SecretEntry): boolean {
82
+ if ((entry.mode ?? "obfuscate") === "obfuscate") {
83
+ if (entry.type === "regex") return true;
84
+ return entry.content.length >= MIN_OBFUSCATE_SECRET_LEN;
85
+ }
86
+ return entry.type === "regex" && entry.replacement === undefined;
87
+ }
88
+
89
+ /**
90
+ * Whether a plain replace-mode replacement string can contribute a fragment that
91
+ * helps the replace phase reconstruct an obfuscate `content`. During obfuscate()'s
92
+ * replace phase the output is a tiling of passthrough bytes (adversary-controlled
93
+ * provider text) and whole replacement outputs; any contiguous occurrence of
94
+ * `content` in that output is covered by interior replacement tiles (each a
95
+ * substring of `content`) bordered by passthrough at the ends, where the border
96
+ * tile may be a suffix of a replacement (forming `content`'s prefix) or a prefix
97
+ * of a replacement (forming `content`'s suffix). An EMPTY replacement deletes its
98
+ * trigger entirely, joining the passthrough on both sides; with adversary-chosen
99
+ * surrounding bytes that can form any non-empty `content` across the deleted gap.
100
+ * So a replacement can help iff it is empty, is a substring of `content`,
101
+ * contains `content`, or shares such a border overlap.
102
+ */
103
+ function replacementCanFormContent(replacement: string, content: string): boolean {
104
+ if (replacement.length === 0) return content.length > 0;
105
+ if (content.includes(replacement) || replacement.includes(content)) return true;
106
+ const maxOverlap = Math.min(replacement.length, content.length);
107
+ for (let k = 1; k <= maxOverlap; k++) {
108
+ // A suffix of the replacement forms the prefix of the content (left border),
109
+ // or a prefix of the replacement forms the suffix of the content (right border).
110
+ if (content.startsWith(replacement.slice(replacement.length - k)) || content.endsWith(replacement.slice(0, k))) {
111
+ return true;
112
+ }
113
+ }
114
+ return false;
115
+ }
116
+
117
+ /**
118
+ * Whether a SET of entries needs the persisted placeholder key. `obfuscate()`
119
+ * applies plain replace-mode mappings before the plain-obfuscate pass, so a plain
120
+ * obfuscate entry only emits a reversible (keyed) placeholder when its content can
121
+ * still appear AFTER the replace phase. When no obfuscate entry can ever produce a
122
+ * placeholder, the persisted key must NOT be required/created — otherwise an
123
+ * effectively replace-only secret set still writes `secret-placeholder.key` and
124
+ * fails startup when the agent config dir is unwritable.
125
+ *
126
+ * The decision models the replace phase as the obfuscator actually runs it:
127
+ * replace mappings are content-keyed (later duplicate wins) and applied in
128
+ * descending content-length order; for a fresh probe (no prior placeholders) that
129
+ * phase is plain sequential substring replacement. A plain obfuscate entry needs
130
+ * the key when its content survives that simulated phase (direct typing) OR when
131
+ * any effective replacement can form the content via tiling — a substring,
132
+ * wholesale superstring, or prefix/suffix border that joins with surrounding
133
+ * passthrough bytes (see `replacementCanFormContent`). This covers direct
134
+ * shadowing (`SECRET -> safe`), reintroduction, duplicate ordering, transitive
135
+ * chains, and context-joined fragments uniformly. Default (omitted) replacements
136
+ * are deterministic, length-preserving, and distinct, so a same-content shadow
137
+ * with no other interacting replacement stays key-free.
138
+ * Replacement outputs are themselves rewritten by every later (shorter-content)
139
+ * replacement before the plain-obfuscate pass sees them, so a fragment that a
140
+ * subsequent replacement erases (`AA -> SEC` then `S -> X` turns every `SEC` into
141
+ * `XEC`) no longer forces the key. Surrounding bytes stay modeled as arbitrary
142
+ * passthrough, so testing the surviving fragment only drops false positives and
143
+ * never under-approximates a real key need.
144
+ */
145
+ export function secretEntriesNeedPlaceholderKey(entries: SecretEntry[]): boolean {
146
+ const replaceMap = new Map<string, string>();
147
+ for (const entry of entries) {
148
+ if (entry.type !== "plain" || (entry.mode ?? "obfuscate") !== "replace") continue;
149
+ replaceMap.set(
150
+ entry.content,
151
+ entry.replacement ?? ensureDistinctReplacement(generateDeterministicReplacement(entry.content), entry.content),
152
+ );
153
+ }
154
+ const replacePhase = [...replaceMap].sort((a, b) => b[0].length - a[0].length);
155
+ // Apply the replace phase from `start` onward. The phase runs in descending
156
+ // content-length order, so a replacement output emitted at index i is rewritten
157
+ // only by the later (shorter-content) replacements at i+1…; `start` 0 models a
158
+ // value typed directly into the input.
159
+ const applyReplacePhaseFrom = (text: string, start: number): string => {
160
+ let result = text;
161
+ for (let i = start; i < replacePhase.length; i++) {
162
+ result = result.split(replacePhase[i][0]).join(replacePhase[i][1]);
163
+ }
164
+ return result;
165
+ };
166
+ return entries.some(entry => {
167
+ if (!secretEntryNeedsPlaceholderKey(entry)) return false;
168
+ // Regex obfuscate entries match dynamically; conservatively require the key.
169
+ if (entry.type !== "plain") return true;
170
+ const content = entry.content;
171
+ if (applyReplacePhaseFrom(content, 0).includes(content)) return true;
172
+ // Test each replacement output in the form it SURVIVES the rest of the phase,
173
+ // so a fragment a later replacement erases no longer forces the key. The
174
+ // content it tiles into must also survive those later replacements: if a
175
+ // shorter-content replacement rewrites the surrounding passthrough bytes
176
+ // (e.g. `AA -> SEC` forms `SEC`+`RET12`, then `R -> X` turns the freshly
177
+ // formed `SECRET12` into `SECXET12`), the content can never reach the
178
+ // obfuscate pass, so the key is not needed. Requiring content stability only
179
+ // drops such false positives — a formation that genuinely survives is still
180
+ // caught at the replacement index that produces it.
181
+ return replacePhase.some(
182
+ ([, replacement], i) =>
183
+ applyReplacePhaseFrom(content, i + 1) === content &&
184
+ replacementCanFormContent(applyReplacePhaseFrom(replacement, i + 1), content),
185
+ );
186
+ });
187
+ }
188
+
189
+ // Derive the model-visible base from a KEYED digest of the secret. xxHash is
190
+ // fast and unkeyed, so a fixed-seed content hash of a low-entropy secret could
191
+ // be dictionaried from the transcript; HMAC-SHA256 under a private per-install
192
+ // key cannot, since the attacker lacks the key.
193
+ export function buildHashBase(key: string, value: string): string {
194
+ const digest = new Bun.CryptoHasher("sha256", key).update(value).digest();
195
+ let v = 0n;
196
+ for (let i = 0; i < 8; i++) v = (v << 8n) | BigInt(digest[i]);
197
+ const radix = BigInt(HASH_CHARS.length);
198
+ let tag = "";
199
+ for (let i = 0; i < HASH_LEN; i++) {
200
+ tag += HASH_CHARS[Number(v % radix)];
201
+ v /= radix;
202
+ }
203
+ return tag;
204
+ }
205
+
206
+ // Build a deterministic, key-derived run of REPLACEMENT_CHARS of the given
207
+ // length. Used to redact a per-chunk replace remainder to a marker that depends
208
+ // only on the per-install key and the remainder length, so a fresh obfuscator
209
+ // reproduces the identical marker (idempotent redaction across restarts) while
210
+ // the run stays unpredictable without the key (raw sentinel-shaped bytes cannot
211
+ // equal the marker, so they are still redacted rather than passed through).
212
+ export function buildKeyedReplacementRun(key: string, length: number): string {
213
+ if (length <= 0) return "";
214
+ const radix = REPLACEMENT_CHARS.length;
215
+ let out = "";
216
+ for (let block = 0; out.length < length; block++) {
217
+ const digest = new Bun.CryptoHasher("sha256", key).update(`replace-chunk\0${length}\0${block}`).digest();
218
+ for (let i = 0; i < digest.length && out.length < length; i++) {
219
+ out += REPLACEMENT_CHARS[digest[i] % radix];
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+
225
+ export function inferCaseHint(secret: string): PlaceholderCaseHint | undefined {
226
+ let hasCased = false;
227
+ let hasUpper = false;
228
+ let hasLower = false;
229
+ let capitalized = true;
230
+ let seenFirstCased = false;
231
+
232
+ for (let i = 0; i < secret.length; i++) {
233
+ const code = secret.charCodeAt(i);
234
+ const isUpper = code >= 65 && code <= 90;
235
+ const isLower = code >= 97 && code <= 122;
236
+ if (!isUpper && !isLower) continue;
237
+
238
+ hasCased = true;
239
+ if (isUpper) {
240
+ hasUpper = true;
241
+ if (seenFirstCased) capitalized = false;
242
+ } else {
243
+ hasLower = true;
244
+ if (!seenFirstCased) capitalized = false;
245
+ }
246
+ seenFirstCased = true;
247
+ }
248
+
249
+ if (!hasCased) return undefined;
250
+ if (hasUpper && !hasLower) return "U";
251
+ if (hasLower && !hasUpper) return "L";
252
+ if (capitalized) return "C";
253
+ return "M";
254
+ }
255
+
256
+ export function buildPlaceholder(hint: PlaceholderCaseHint | undefined, base: string, friendlyName?: string): string {
257
+ const prefix = friendlyName ? `${friendlyName}_` : "";
258
+ return hint ? `$$${prefix}${base}:${hint}$$` : `$$${prefix}${base}$$`;
259
+ }
260
+
261
+ /** Regex matching `$$HASH$$`, `$$HASH:U$$`, and `$$FRIENDLY_HASH(:hint)$$` placeholders. */
262
+ export const PLACEHOLDER_RE = /\$\$(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?\$\$/g;
263
+
264
+ export function resumePlaceholderScanAfterRejectedCandidate(match: RegExpExecArray): void {
265
+ // RegExp#exec does not find overlapping matches. Restart at the rejected
266
+ // candidate's closing delimiter, which can open an immediately adjacent placeholder.
267
+ PLACEHOLDER_RE.lastIndex = match.index + match[0].length - 2;
268
+ }
269
+
270
+ export function placeholderWithoutFriendlyName(placeholder: string): string | undefined {
271
+ const match = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder);
272
+ return match ? `$$${match[1]}$$` : undefined;
273
+ }
274
+
275
+ export function lookupFriendlyPlaceholderAlias(
276
+ deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
277
+ placeholder: string,
278
+ ): { secret: string; recursive: boolean } | undefined {
279
+ const direct = deobfuscateMap.get(placeholder);
280
+ if (direct !== undefined) return direct;
281
+ const unprefixed = placeholderWithoutFriendlyName(placeholder);
282
+ return unprefixed !== undefined ? deobfuscateMap.get(unprefixed) : undefined;
283
+ }
284
+
285
+ const PENDING_PLACEHOLDER_SUFFIX_RE = /(?:\$\$(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?|\$)$/;
286
+
287
+ // Withhold a trailing run that could be the start of a placeholder from streamed
288
+ // deltas, so a partial token is never emitted before deobfuscation can replace
289
+ // it. A lone trailing delimiter character is always buffered because it can open
290
+ // a placeholder; the final non-streamed flush re-emits it when no token follows.
291
+ export function stripPendingSecretPlaceholderSuffix(text: string): string {
292
+ const pendingPlaceholderStart = text.match(PENDING_PLACEHOLDER_SUFFIX_RE);
293
+ if (pendingPlaceholderStart?.index === undefined) return text;
294
+ return text.slice(0, pendingPlaceholderStart.index);
295
+ }
296
+
297
+ export interface RegexScanSegment {
298
+ scanStart: number;
299
+ scanEnd: number;
300
+ textStart: number;
301
+ textEnd: number;
302
+ generatedPlaceholder: boolean;
303
+ recursive: boolean;
304
+ }
305
+
306
+ export interface ReplaceRegexScan {
307
+ text: string;
308
+ segments: RegexScanSegment[];
309
+ }
@@ -0,0 +1,216 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Deterministic replacement generation
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+
5
+ export const REPLACEMENT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
6
+ const NONMATCHING_REPLACEMENT_CHARS = `${REPLACEMENT_CHARS}!#$%&()*+,-./:;<=>?@[]^_{|}~`;
7
+ // Whitespace bytes used to build last-resort redactions for a default replace
8
+ // regex that matches every non-whitespace candidate (e.g. `\S{n}`). Only
9
+ // `space`/`tab` are used — never a line terminator — so a `.`-style
10
+ // match-everything regex (which matches space and tab but not `\n`) still
11
+ // exhausts to the sentinel instead of redacting to a newline run.
12
+ const WHITESPACE_REPLACEMENT_CHARS = " \t";
13
+
14
+ /** Generate a deterministic same-length replacement string from a secret value. */
15
+ export function generateDeterministicReplacement(secret: string): string {
16
+ if (secret.length === 0) return "";
17
+ // Prefix generated chunks with a fixed `ZZ` so re-redacting an already-emitted
18
+ // 1–2 char chunk is a fixed point (the deterministic replacement of a <=2-char
19
+ // value is itself `Z`/`ZZ`), keeping short default-replacement remainders next
20
+ // to a reversible placeholder stable across an obfuscator restart.
21
+ const hash = BigInt(Bun.hash(secret));
22
+ const chars = secret.length === 1 ? ["Z"] : ["Z", "Z"];
23
+ let h = hash;
24
+ for (let i = chars.length; i < secret.length; i++) {
25
+ h = h ^ (BigInt(i + 1) * 0x9e3779b97f4a7c15n);
26
+ const idx = Number((h < 0n ? -h : h) % BigInt(REPLACEMENT_CHARS.length));
27
+ chars.push(REPLACEMENT_CHARS[idx]);
28
+ }
29
+ return chars.join("");
30
+ }
31
+
32
+ /**
33
+ * Force a length-preserving deterministic replacement to differ from the secret
34
+ * it stands in for. `generateDeterministicReplacement` seeds its first 1–2 chars
35
+ * with the `Z`/`ZZ` sentinel, so a whole configured value that is exactly `Z` or
36
+ * `ZZ` (or an astronomically unlikely longer hash collision) would otherwise be
37
+ * emitted unchanged and ship the raw secret to the provider. Flip the first char
38
+ * to a fixed different glyph: same length, still deterministic, guaranteed != the
39
+ * secret. Only safe for a whole CONFIGURED value (a plain secret matches its own
40
+ * literal, so the perturbed output is no longer matched and stays a fixed point);
41
+ * per-chunk remainders must keep the sentinel to remain idempotent across restart.
42
+ */
43
+ export function ensureDistinctReplacement(replacement: string, secret: string): string {
44
+ if (replacement.length === 0 || replacement !== secret) return replacement;
45
+ const alt = replacement[0] === REPLACEMENT_CHARS[0] ? REPLACEMENT_CHARS[1] : REPLACEMENT_CHARS[0];
46
+ return alt + replacement.slice(1);
47
+ }
48
+
49
+ // How far left of the matched span the re-match scan begins looking for a match
50
+ // that overlaps the candidate. This bounds ONLY the match-start search position,
51
+ // never the lookbehind/lookahead context: the probe below substitutes the
52
+ // candidate into the FULL text, so a regex's lookbehind/lookahead assertions
53
+ // always evaluate against complete context regardless of width. The single
54
+ // re-match this misses is one that begins more than this many bytes before the
55
+ // span and extends into it (a single match longer than the window) — that only
56
+ // churns the chosen redaction marker between candidates, never back to the raw
57
+ // matched value, so it cannot leak a secret.
58
+ const REGEX_REMATCH_BACKSCAN = 512;
59
+
60
+ export interface RegexMatchContext {
61
+ /** Full text the match was found in (positions are offsets into it). */
62
+ text: string;
63
+ /** Start/end of the matched span being replaced. */
64
+ start: number;
65
+ end: number;
66
+ }
67
+
68
+ /**
69
+ * Whether `candidate`, substituted for the matched span in its surrounding text,
70
+ * is re-matched by `regex` at its own position. A replace-mode regex that depends
71
+ * on context (lookbehind/lookahead/`\b`) can match a candidate that does NOT match
72
+ * in isolation: e.g. `(?<=api=)[AZ]` never matches a bare `A`, but `api=A` does, so
73
+ * a candidate `A` chosen by an isolation test is re-redacted on the next obfuscate()
74
+ * pass and can oscillate back to the raw matched value. The probe substitutes the
75
+ * candidate into the FULL text — not a truncated window — so a wide lookbehind or
76
+ * lookahead (e.g. `(?<=A{600})`) still evaluates against the context that makes it
77
+ * match. Truncating that context dropped the assertion's reach and falsely
78
+ * accepted an oscillating, leaky candidate. The scan starts a bounded distance
79
+ * left of the span and stops once a match begins at/after the span's end (matches
80
+ * arrive in order), keeping per-candidate cost independent of total text length.
81
+ */
82
+ export function regexRematchesInContext(candidate: string, regex: RegExp, ctx: RegexMatchContext): boolean {
83
+ const probe = ctx.text.slice(0, ctx.start) + candidate + ctx.text.slice(ctx.end);
84
+ const spanStart = ctx.start;
85
+ const spanEnd = spanStart + candidate.length;
86
+ regex.lastIndex = Math.max(0, spanStart - REGEX_REMATCH_BACKSCAN);
87
+ for (let m = regex.exec(probe); m !== null; m = regex.exec(probe)) {
88
+ const matchStart = m.index;
89
+ const matchEnd = m.index + m[0].length;
90
+ // Matches arrive in increasing position; once one starts at or past the
91
+ // span's end it cannot cover the candidate, and neither can any later one.
92
+ if (matchStart >= spanEnd) break;
93
+ // A match overlapping the candidate's own bytes means those bytes get
94
+ // re-redacted on a later pass — not a fixed point.
95
+ if (matchEnd > spanStart) return true;
96
+ // Zero-width matches do not advance lastIndex; step past to avoid a loop.
97
+ if (m[0].length === 0) regex.lastIndex++;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * Search same-length replacements for one the regex does NOT match, so a default
104
+ * regex secret whose deterministic replacement collides with its own value (the
105
+ * `Z`/`ZZ` sentinel, or an astronomical hash collision) is still redacted to a
106
+ * STABLE nonmatching value instead of shipping the raw secret. A nonmatching
107
+ * candidate is a fixed point under re-obfuscation — the regex never re-matches it,
108
+ * so it cannot re-leak on a later pass. The search stays bounded to O(length *
109
+ * alphabet) regardless of value length: first exhaust every single-position
110
+ * substitution against a deterministic baseline (`AAAA…`, then `!AAA…`, `A!AA…`,
111
+ * …) so any regex that only needs one out-of-class byte — regardless of position —
112
+ * is found in a handful of probes rather than enumerating every combination (which
113
+ * for a 3-byte match-everything config, e.g. `[\s\S]{3}`, would otherwise run
114
+ * 90**3 = 729000 candidates through the regex on every single match, stalling
115
+ * provider requests). Candidates are enumerated deterministically over a stable
116
+ * ASCII alphabet: alphanumerics first (usually enough), then punctuation fallback
117
+ * bytes when the regex covers every alphanumeric candidate. When the regex still
118
+ * matches around a lone perturbed byte (for example `[A-Za-z0-9].*` matching the
119
+ * unperturbed tail), full-width same-byte candidates (`!!!!!`, `_____`, …) are
120
+ * tried next. When the regex covers every non-whitespace candidate (e.g. `\S{n}`),
121
+ * whitespace markers (a full space/tab run, then a single whitespace byte among
122
+ * non-whitespace filler) are tried as a last resort. A genuine match-everything
123
+ * regex (`.`/`[\s\S]`, which also matches space and tab) still exhausts this bounded
124
+ * sweep and returns undefined, letting the caller keep its own fixed-point fallback
125
+ * — bounded search can in principle miss an escape that depends jointly on
126
+ * multiple positions in a way no single-position swap reaches, but no realistic
127
+ * secret-redaction regex (character classes, literal matches, anchored/bounded
128
+ * repeats) has that shape.
129
+ */
130
+ export function findNonMatchingReplacement(
131
+ value: string,
132
+ regex: RegExp,
133
+ context: RegexMatchContext,
134
+ ): string | undefined {
135
+ const len = value.length;
136
+ if (len === 0) return undefined;
137
+ // Exhaust every single-position substitution against the deterministic baseline
138
+ // first (covers the common case cheaply), then fall back to full-width same-byte
139
+ // candidates for a regex that only rejects a lone perturbed byte in context.
140
+ const baseline = NONMATCHING_REPLACEMENT_CHARS[0].repeat(len);
141
+ for (let position = 0; position < len; position++) {
142
+ for (const ch of NONMATCHING_REPLACEMENT_CHARS) {
143
+ const candidate = `${baseline.slice(0, position)}${ch}${baseline.slice(position + 1)}`;
144
+ if (candidate === value) continue;
145
+ if (!regexRematchesInContext(candidate, regex, context)) return candidate;
146
+ }
147
+ }
148
+ // If the regex can still match around a lone punctuation byte (for example
149
+ // `[A-Za-z0-9].*` matching the `AAAA` tail of `!AAAA`), try full-width
150
+ // same-byte fallbacks like `!!!!!`, `_____`, etc. before giving up.
151
+ for (const ch of NONMATCHING_REPLACEMENT_CHARS) {
152
+ const candidate = ch.repeat(len);
153
+ if (candidate === value) continue;
154
+ if (!regexRematchesInContext(candidate, regex, context)) return candidate;
155
+ }
156
+ return findWhitespaceFallbackReplacement(value, regex, context);
157
+ }
158
+
159
+ /**
160
+ * Last-resort fallback for a default replace regex that matches every
161
+ * non-whitespace candidate. Builds same-length whitespace markers the regex
162
+ * cannot match: first a full space/tab run (handles `\S`-class patterns), then a
163
+ * single whitespace byte among non-whitespace filler (` AAAA`, `A AAA`, …). The
164
+ * mixed marker defeats regexes that ALSO match all-space/all-tab runs, e.g.
165
+ * `(?:\S{n}| {n}|\t{n})`, because the lone whitespace byte breaks every
166
+ * fixed-length run. A genuine match-everything regex (`.`/`[\s\S]`) matches the
167
+ * filler and the whitespace alike, so this still returns undefined there, keeping
168
+ * the caller's sentinel as the sole fixed point.
169
+ */
170
+ function findWhitespaceFallbackReplacement(
171
+ value: string,
172
+ regex: RegExp,
173
+ context: RegexMatchContext,
174
+ ): string | undefined {
175
+ const len = value.length;
176
+ const filler = NONMATCHING_REPLACEMENT_CHARS[0];
177
+ for (const ws of WHITESPACE_REPLACEMENT_CHARS) {
178
+ const full = ws.repeat(len);
179
+ if (full !== value) {
180
+ if (!regexRematchesInContext(full, regex, context)) return full;
181
+ }
182
+ for (let pos = 0; pos < len; pos++) {
183
+ const candidate = `${filler.repeat(pos)}${ws}${filler.repeat(len - pos - 1)}`;
184
+ if (candidate === value) continue;
185
+ if (!regexRematchesInContext(candidate, regex, context)) return candidate;
186
+ }
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ /**
192
+ * Whether a default (no custom `replacement`) replace-mode regex can never
193
+ * safely redact a 1-2 char match: `findNonMatchingReplacement`'s bounded
194
+ * search — the same search `#generateRegexReplacement` runs at match time —
195
+ * finds no candidate the regex fails to re-match. This holds independent of
196
+ * any actual per-install key: the search already exhausts every character in
197
+ * `REPLACEMENT_CHARS` (the alphabet `buildKeyedReplacementRun` draws its
198
+ * fallback marker from) plus punctuation and whitespace, so if none of those
199
+ * escape the regex, no key-derived marker drawn from the same alphabet can
200
+ * either — the marker is guaranteed to re-match too, making every such match
201
+ * unresolvable: the fallback could only ever emit the raw matched text
202
+ * unchanged. Probed with a value (`"\0".repeat(length)`) the bounded search
203
+ * never treats as a real candidate, so the result depends only on the
204
+ * regex's own matching behavior, not on this specific probe.
205
+ */
206
+ export function regexHasUnresolvableShortMatchFallback(regex: RegExp): boolean {
207
+ return ([1, 2] as const).some(length => {
208
+ const probe = "\u0000".repeat(length);
209
+ const savedLastIndex = regex.lastIndex;
210
+ try {
211
+ return findNonMatchingReplacement(probe, regex, { text: probe, start: 0, end: length }) === undefined;
212
+ } finally {
213
+ regex.lastIndex = savedLastIndex;
214
+ }
215
+ });
216
+ }
@@ -44,6 +44,12 @@ export const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 1_500;
44
44
  /** Options controlling session disposal. */
45
45
  export interface AgentSessionDisposeOptions {
46
46
  mnemopiConsolidateTimeoutMs?: number;
47
+ /**
48
+ * Deadline for the settle/drain wait before the terminal memory release
49
+ * (default 5s). The bounded-teardown paths (signal handlers, tests) may
50
+ * shorten it; late event handlers are still finalized after they settle.
51
+ */
52
+ drainTimeoutMs?: number;
47
53
  /**
48
54
  * Postmortem reason that triggered this dispose (signal/fatal teardown
49
55
  * paths). When set, the persisted `session_exit` diagnostic records it