@gajae-code/coding-agent 0.12.21 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/dist/types/cli/args.d.ts +5 -2
  3. package/dist/types/cli/root-flags.d.ts +246 -0
  4. package/dist/types/cli.d.ts +11 -19
  5. package/dist/types/commands/daemon.d.ts +0 -30
  6. package/dist/types/commands/launch.d.ts +11 -19
  7. package/dist/types/commands/quick-lane.d.ts +32 -0
  8. package/dist/types/commands/sdk.d.ts +21 -15
  9. package/dist/types/config/file-lock-gc.d.ts +2 -0
  10. package/dist/types/config/model-equivalence.d.ts +20 -0
  11. package/dist/types/config/model-profile-activation.d.ts +62 -17
  12. package/dist/types/config/model-profiles.d.ts +10 -0
  13. package/dist/types/config/model-registry.d.ts +68 -1
  14. package/dist/types/config/model-resolver.d.ts +15 -4
  15. package/dist/types/config/models-config-schema.d.ts +7 -2
  16. package/dist/types/config/provider-selection-policy.d.ts +62 -0
  17. package/dist/types/config/settings-schema.d.ts +36 -78
  18. package/dist/types/config/settings.d.ts +2 -0
  19. package/dist/types/edit/renderer.d.ts +5 -2
  20. package/dist/types/edit/streaming.d.ts +13 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  22. package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
  23. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
  25. package/dist/types/internal-urls/local-protocol.d.ts +1 -0
  26. package/dist/types/modes/components/model-selector.d.ts +0 -2
  27. package/dist/types/modes/components/provider-order-context.d.ts +45 -0
  28. package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
  29. package/dist/types/modes/components/settings-defs.d.ts +5 -1
  30. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  31. package/dist/types/quick-lane/classify.d.ts +40 -0
  32. package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
  33. package/dist/types/sdk/broker/broker.d.ts +56 -2
  34. package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
  35. package/dist/types/sdk/broker/session-index.d.ts +83 -2
  36. package/dist/types/sdk/broker/state-version.d.ts +1 -1
  37. package/dist/types/sdk/bus/notification-service.d.ts +6 -0
  38. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
  41. package/dist/types/sdk/cli/rows.d.ts +98 -0
  42. package/dist/types/sdk/cli/session-cli.d.ts +35 -3
  43. package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
  44. package/dist/types/sdk/elevation/audit.d.ts +93 -0
  45. package/dist/types/sdk/elevation/capability.d.ts +3 -0
  46. package/dist/types/sdk/elevation/digest.d.ts +72 -0
  47. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
  48. package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
  49. package/dist/types/sdk/elevation/owner.d.ts +48 -0
  50. package/dist/types/sdk/guides/cache.d.ts +98 -0
  51. package/dist/types/sdk/guides/catalog.d.ts +156 -0
  52. package/dist/types/sdk/guides/cli.d.ts +39 -0
  53. package/dist/types/sdk/guides/manifest.d.ts +66 -0
  54. package/dist/types/sdk/guides/verify.d.ts +66 -0
  55. package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
  56. package/dist/types/sdk/host/control/index.d.ts +1 -1
  57. package/dist/types/sdk/host/control/operations.d.ts +3 -2
  58. package/dist/types/sdk/host/query/cursor.d.ts +10 -0
  59. package/dist/types/sdk/host/query/handlers.d.ts +21 -0
  60. package/dist/types/sdk/host/session-runtime.d.ts +9 -0
  61. package/dist/types/sdk/mcp/server.d.ts +2 -2
  62. package/dist/types/sdk/prompt-status.d.ts +11 -0
  63. package/dist/types/sdk/session.d.ts +4 -0
  64. package/dist/types/sdk/transport/index.d.ts +1 -0
  65. package/dist/types/sdk/transport/relay.d.ts +1 -0
  66. package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
  67. package/dist/types/session/agent-session.d.ts +32 -18
  68. package/dist/types/session/blob-store.d.ts +13 -7
  69. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  70. package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
  71. package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
  72. package/dist/types/session/session-manager.d.ts +139 -10
  73. package/dist/types/session/session-storage.d.ts +48 -0
  74. package/dist/types/setup/provider-onboarding.d.ts +7 -1
  75. package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
  76. package/dist/types/stt/setup.d.ts +2 -0
  77. package/dist/types/task/executor.d.ts +10 -2
  78. package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
  79. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  80. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  81. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  82. package/dist/types/tools/browser.d.ts +2 -0
  83. package/dist/types/tools/image-gen.d.ts +14 -18
  84. package/dist/types/tools/index.d.ts +2 -0
  85. package/dist/types/tools/resource-gc.d.ts +2 -0
  86. package/dist/types/utils/pasted-image-path.d.ts +6 -0
  87. package/package.json +9 -9
  88. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  89. package/scripts/run-test-manifest.ts +61 -21
  90. package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
  91. package/src/cli/args.ts +62 -9
  92. package/src/cli/completion-cli.ts +1 -0
  93. package/src/cli/fast-help.ts +1 -0
  94. package/src/cli/root-flags.ts +100 -0
  95. package/src/cli/setup-cli.ts +6 -2
  96. package/src/cli.ts +42 -65
  97. package/src/commands/acp.ts +1 -1
  98. package/src/commands/daemon.ts +2 -31
  99. package/src/commands/gc.ts +1 -1
  100. package/src/commands/launch.ts +8 -129
  101. package/src/commands/quick-lane.ts +51 -0
  102. package/src/commands/sdk.ts +200 -26
  103. package/src/commands/setup.ts +4 -2
  104. package/src/config/file-lock-gc.ts +4 -1
  105. package/src/config/model-equivalence.ts +76 -7
  106. package/src/config/model-profile-activation.ts +1115 -265
  107. package/src/config/model-profiles.ts +134 -2
  108. package/src/config/model-registry.ts +352 -50
  109. package/src/config/model-resolver.ts +289 -38
  110. package/src/config/models-config-schema.ts +11 -4
  111. package/src/config/provider-selection-policy.ts +146 -0
  112. package/src/config/settings-schema.ts +50 -77
  113. package/src/config/settings.ts +40 -0
  114. package/src/coordinator-mcp/server.ts +32 -2
  115. package/src/edit/renderer.ts +175 -223
  116. package/src/edit/streaming.ts +108 -1
  117. package/src/export/html/index.ts +57 -7
  118. package/src/extensibility/custom-tools/types.ts +2 -0
  119. package/src/extensibility/extensions/compact-handler.ts +3 -2
  120. package/src/extensibility/extensions/runner.ts +6 -0
  121. package/src/extensibility/extensions/types.ts +3 -0
  122. package/src/gjc-runtime/gc-runtime.ts +379 -20
  123. package/src/gjc-runtime/launch-worktree.ts +5 -1
  124. package/src/gjc-runtime/team-runtime.ts +59 -2
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
  126. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
  127. package/src/internal-urls/docs-index.generated.ts +17 -13
  128. package/src/internal-urls/local-protocol.ts +1 -0
  129. package/src/main.ts +40 -3
  130. package/src/memories/index.ts +4 -2
  131. package/src/modes/acp/acp-agent.ts +235 -91
  132. package/src/modes/components/model-selector.ts +206 -42
  133. package/src/modes/components/provider-order-context.ts +197 -0
  134. package/src/modes/components/provider-order-editor.ts +441 -0
  135. package/src/modes/components/settings-defs.ts +21 -1
  136. package/src/modes/components/settings-selector.ts +45 -2
  137. package/src/modes/components/tool-execution.ts +47 -15
  138. package/src/modes/controllers/command-controller.ts +6 -3
  139. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  140. package/src/modes/controllers/input-controller.ts +73 -2
  141. package/src/modes/controllers/selector-controller.ts +260 -153
  142. package/src/modes/prompt-suggestion-controller.ts +1 -1
  143. package/src/modes/runtime-init.ts +1 -0
  144. package/src/modes/utils/ui-helpers.ts +18 -0
  145. package/src/prompts/tools/browser.md +1 -0
  146. package/src/quick-lane/classify.ts +257 -0
  147. package/src/rlm/index.ts +1 -1
  148. package/src/runtime/stt-settings-setup.ts +57 -0
  149. package/src/sdk/acp/adapter.ts +2 -1
  150. package/src/sdk/broker/broker.ts +780 -12
  151. package/src/sdk/broker/lifecycle.ts +70 -29
  152. package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
  153. package/src/sdk/broker/session-index.ts +463 -48
  154. package/src/sdk/broker/state-version.ts +13 -9
  155. package/src/sdk/broker/transport.ts +14 -1
  156. package/src/sdk/bus/index.ts +34 -13
  157. package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
  158. package/src/sdk/bus/notification-service.ts +47 -1
  159. package/src/sdk/bus/prompt-reconciliation.ts +22 -3
  160. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  161. package/src/sdk/bus/telegram-daemon.ts +54 -3
  162. package/src/sdk/cli/rows.ts +247 -0
  163. package/src/sdk/cli/session-cli.ts +930 -79
  164. package/src/sdk/elevation/allowlist.ts +54 -0
  165. package/src/sdk/elevation/audit.ts +208 -0
  166. package/src/sdk/elevation/capability.ts +41 -0
  167. package/src/sdk/elevation/digest.ts +129 -0
  168. package/src/sdk/elevation/dispatch-receipt.ts +184 -0
  169. package/src/sdk/elevation/grant-ledger.ts +1142 -0
  170. package/src/sdk/elevation/owner.ts +109 -0
  171. package/src/sdk/guides/cache.ts +363 -0
  172. package/src/sdk/guides/catalog.ts +554 -0
  173. package/src/sdk/guides/cli.ts +235 -0
  174. package/src/sdk/guides/manifest.ts +152 -0
  175. package/src/sdk/guides/verify.ts +178 -0
  176. package/src/sdk/host/control/dispatch.ts +49 -1
  177. package/src/sdk/host/control/index.ts +1 -0
  178. package/src/sdk/host/control/operations.ts +8 -1
  179. package/src/sdk/host/query/cursor.ts +47 -3
  180. package/src/sdk/host/query/handlers.ts +155 -2
  181. package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
  182. package/src/sdk/host/session-runtime.test.ts +391 -1
  183. package/src/sdk/host/session-runtime.ts +308 -73
  184. package/src/sdk/host/surface-policy.ts +3 -0
  185. package/src/sdk/host/websocket-transport.ts +7 -1
  186. package/src/sdk/mcp/server.ts +88 -15
  187. package/src/sdk/prompt-status.ts +8 -0
  188. package/src/sdk/protocol/operation-inventory.generated.json +62 -0
  189. package/src/sdk/protocol/operation-registry.ts +3 -2
  190. package/src/sdk/session.ts +87 -39
  191. package/src/sdk/transport/index.ts +1 -0
  192. package/src/sdk/transport/relay.ts +3 -0
  193. package/src/sdk/transport/serve-cli.ts +111 -24
  194. package/src/secrets/obfuscator.ts +92 -12
  195. package/src/session/agent-session.ts +322 -93
  196. package/src/session/blob-store.ts +60 -8
  197. package/src/session/fallback-chain-controller.ts +35 -0
  198. package/src/session/internal/managed-session-scope.ts +25 -3
  199. package/src/session/internal/managed-session-storage.ts +574 -73
  200. package/src/session/internal/session-memory-sidecar.ts +19 -29
  201. package/src/session/session-manager.ts +2166 -743
  202. package/src/session/session-storage.ts +715 -48
  203. package/src/setup/provider-onboarding.ts +19 -3
  204. package/src/setup/provider-presets.json +3 -1
  205. package/src/slash-commands/builtin-registry.ts +85 -3
  206. package/src/stt/downloader.ts +2 -3
  207. package/src/stt/setup.ts +29 -4
  208. package/src/task/executor.ts +22 -18
  209. package/src/task/index.ts +24 -1
  210. package/src/tools/archive-reader.ts +11 -2
  211. package/src/tools/browser/runtime-diagnostics.ts +249 -0
  212. package/src/tools/browser/tab-protocol.ts +4 -0
  213. package/src/tools/browser/tab-supervisor.ts +5 -0
  214. package/src/tools/browser/tab-worker.ts +36 -1
  215. package/src/tools/browser.ts +7 -0
  216. package/src/tools/image-gen.ts +110 -166
  217. package/src/tools/index.ts +2 -0
  218. package/src/tools/read.ts +1 -1
  219. package/src/tools/resource-gc.ts +6 -1
  220. package/src/tools/tool-catalog.generated.ts +5 -1
  221. package/src/utils/pasted-image-path.ts +93 -0
  222. package/src/web/search/index.ts +3 -3
@@ -1,9 +1,10 @@
1
+ import { getAgentDir } from "@gajae-code/utils";
1
2
  import { CliParseError } from "@gajae-code/utils/cli";
2
- import type { SdkSessionEndpoint } from "../client/discovery";
3
- import { listSdkSessionEndpoints } from "../client/discovery";
4
- import { type SdkEndpointSelectionError, selectLiveEndpoint } from "../client/liveness";
3
+ import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../client";
5
4
  import { DEFAULT_PENDING_CEILING_BYTES, MIN_PENDING_CEILING_BYTES, startSocketServe, startStdioServe } from "./index";
6
5
 
6
+ const MAX_SESSION_LIST_PAGES = 10_000;
7
+
7
8
  type ServeMode = { kind: "stdio" } | { kind: "socket"; socketPath: string };
8
9
 
9
10
  interface ServeArguments {
@@ -67,11 +68,72 @@ export function resolveServePendingCeiling(flagValue: string | undefined, envVal
67
68
  return ceiling;
68
69
  }
69
70
 
70
- function isSelectionError(value: SdkSessionEndpoint | SdkEndpointSelectionError): value is SdkEndpointSelectionError {
71
- return "code" in value;
71
+ type BrokerSessionRow = { sessionId: string; live: boolean; ambiguous: boolean };
72
+
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ }
76
+
77
+ /** Extracts the broker `result` envelope, converting an explicit error frame into a typed throw. */
78
+ function brokerResult(value: unknown): Record<string, unknown> {
79
+ if (isRecord(value) && value.ok === false) {
80
+ const error = isRecord(value.error) ? value.error : {};
81
+ const code = typeof error.code === "string" ? error.code : "unavailable";
82
+ const message = typeof error.message === "string" ? error.message : "SDK broker request failed";
83
+ throw new SdkClientError(code, message, value.error);
84
+ }
85
+ return isRecord(value) && isRecord(value.result) ? value.result : {};
86
+ }
87
+
88
+ function brokerSessionRows(value: Record<string, unknown>): BrokerSessionRow[] {
89
+ if (!Array.isArray(value.sessions)) return [];
90
+ return value.sessions.flatMap(item => {
91
+ if (!isRecord(item) || typeof item.sessionId !== "string" || !item.sessionId) return [];
92
+ return [{ sessionId: item.sessionId, live: item.live === true, ambiguous: item.ambiguous === true }];
93
+ });
72
94
  }
73
95
 
74
- /** Attaches a stdio or Unix-socket relay to one live SDK session endpoint. */
96
+ /**
97
+ * Exhausts broker `session.list` cursor pagination (default page limit 100) to
98
+ * one full snapshot; exported for tests.
99
+ */
100
+ export async function listBrokerSessions(broker: SdkClient): Promise<BrokerSessionRow[]> {
101
+ const rows: BrokerSessionRow[] = [];
102
+ let cursor: string | undefined;
103
+ for (let pageCount = 0; pageCount < MAX_SESSION_LIST_PAGES; pageCount++) {
104
+ const listed = brokerResult(await broker.global("session.list", cursor === undefined ? {} : { cursor }));
105
+ rows.push(...brokerSessionRows(listed));
106
+ const nextCursor =
107
+ typeof listed.continuationCursor === "string" && listed.continuationCursor.length > 0
108
+ ? listed.continuationCursor
109
+ : undefined;
110
+ if (!nextCursor) return rows;
111
+ cursor = nextCursor;
112
+ }
113
+ throw new SdkClientError("protocol_error", "session.list exceeded the page budget.");
114
+ }
115
+
116
+ /** Selects the session to serve through broker `session.list` truth (C10); exported for tests. */
117
+ export function selectBrokerSession(sessions: BrokerSessionRow[], explicitSessionId: string | undefined): string {
118
+ if (explicitSessionId !== undefined) {
119
+ const row = sessions.find(session => session.sessionId === explicitSessionId);
120
+ if (!row) throw new Error(`not_found: session ${explicitSessionId} is not indexed by the broker`);
121
+ if (row.ambiguous) throw new Error("ambiguous_session: session id maps to more than one state root");
122
+ if (!row.live) throw new Error(`endpoint_stale: session ${explicitSessionId} endpoint is not live`);
123
+ return row.sessionId;
124
+ }
125
+ const live = sessions.filter(session => session.live && !session.ambiguous);
126
+ if (live.length === 0) throw new Error("no_live_endpoint: no live session endpoint");
127
+ if (live.length > 1) throw new Error("multiple_live_endpoints: more than one live session; specify --session <id>");
128
+ return live[0]!.sessionId;
129
+ }
130
+
131
+ /**
132
+ * Attaches a stdio or Unix-socket relay to one live SDK session endpoint.
133
+ * Session targeting is broker-bound (C10): `session.list` resolves the session
134
+ * and `session.get_endpoint` mints the exact credential — never a direct
135
+ * endpoint-file read. A missing or unreachable broker fails closed.
136
+ */
75
137
  export async function runSdkServe(argv: string[]): Promise<void> {
76
138
  const parsed = parseServeArguments(argv);
77
139
  if (parsed.mode.kind === "socket" && process.platform === "win32")
@@ -80,26 +142,51 @@ export async function runSdkServe(argv: string[]): Promise<void> {
80
142
  parsed.pendingCeiling,
81
143
  process.env.GJC_SDK_SERVE_PENDING_CEILING_BYTES,
82
144
  );
83
- const discovered = await listSdkSessionEndpoints(process.cwd());
84
- const selected = selectLiveEndpoint(discovered.endpoints, parsed.sessionId);
85
- if (isSelectionError(selected)) {
86
- const sessionHint = parsed.sessionId ? ` for session ${parsed.sessionId}` : "; specify --session <id>";
87
- throw new Error(`${selected.code}${sessionHint}`);
145
+ const discovery = await readSdkBrokerDiscovery(getAgentDir());
146
+ if (!discovery) throw new Error("broker_unavailable: SDK broker is not running");
147
+ let broker: SdkClient;
148
+ try {
149
+ broker = await SdkClient.connect(discovery.url, discovery.token);
150
+ } catch {
151
+ throw new Error("broker_unavailable: SDK broker is not reachable");
88
152
  }
89
- const options = { url: selected.url, token: selected.token, pendingCeilingBytes };
90
- const handle =
91
- parsed.mode.kind === "stdio"
92
- ? await startStdioServe(options)
93
- : await startSocketServe({ ...options, socketPath: parsed.mode.socketPath });
94
- const stop = () => {
95
- void handle.close();
96
- };
97
- process.once("SIGINT", stop);
98
- process.once("SIGTERM", stop);
99
153
  try {
100
- await handle.done;
154
+ const sessionId = selectBrokerSession(await listBrokerSessions(broker), parsed.sessionId);
155
+ const endpoint = brokerResult(await broker.global("session.get_endpoint", { sessionId }));
156
+ const url = typeof endpoint.url === "string" && endpoint.url ? endpoint.url : undefined;
157
+ const token = typeof endpoint.token === "string" ? endpoint.token : "";
158
+ if (!url) throw new Error("unavailable: broker returned an invalid endpoint record");
159
+ const options = {
160
+ url,
161
+ token,
162
+ pendingCeilingBytes,
163
+ validateDownstreamFrame: (source: string): boolean => {
164
+ try {
165
+ const frame = JSON.parse(source) as { type?: unknown; elevationRequestId?: unknown };
166
+ return frame.type !== "control_request" || frame.elevationRequestId === undefined;
167
+ } catch {
168
+ return true;
169
+ }
170
+ },
171
+ };
172
+ const handle =
173
+ parsed.mode.kind === "stdio"
174
+ ? await startStdioServe(options)
175
+ : await startSocketServe({ ...options, socketPath: parsed.mode.socketPath });
176
+ const stop = () => {
177
+ void handle.close();
178
+ };
179
+ process.once("SIGINT", stop);
180
+ process.once("SIGTERM", stop);
181
+ try {
182
+ await handle.done;
183
+ } finally {
184
+ process.removeListener("SIGINT", stop);
185
+ process.removeListener("SIGTERM", stop);
186
+ }
187
+ } catch (error) {
188
+ throw error instanceof SdkClientError ? new Error(`${error.code}: ${error.message}`) : error;
101
189
  } finally {
102
- process.removeListener("SIGINT", stop);
103
- process.removeListener("SIGTERM", stop);
190
+ await broker.close();
104
191
  }
105
192
  }
@@ -21,17 +21,95 @@ export interface SecretEntry {
21
21
 
22
22
  const REPLACEMENT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
23
23
 
24
- /** Generate a deterministic same-length replacement string from a secret value. */
25
- function generateDeterministicReplacement(secret: string): string {
26
- // Simple hash: use Bun.hash for speed, seed from the secret bytes
27
- const hash = BigInt(Bun.hash(secret));
24
+ /**
25
+ * Domain separator for replace-mode replacement derivation. Distinct from
26
+ * `PLACEHOLDER_DOMAIN` so a value minted under one construction can never be
27
+ * confused with a value minted under the other, even with the same key.
28
+ */
29
+ const REPLACEMENT_DOMAIN = "gjc.secret-obfuscation.replacement.v1\0";
30
+
31
+ /** One HMAC-SHA256 digest block length in bytes. */
32
+ const REPLACEMENT_DIGEST_BYTES = 32;
33
+
34
+ /** Largest accepted byte for uniform rejection sampling (256 - (256 % 62)). */
35
+ const REPLACEMENT_REJECT_THRESHOLD = 248;
36
+
37
+ /**
38
+ * Generate a deterministic, keyed, same-length replacement string from a
39
+ * secret value.
40
+ *
41
+ * COMPATIBILITY CONTRACT (required behavior, must never regress):
42
+ * - Deterministic within a process: the same secret under the same key always
43
+ * yields the same replacement, independent of entry order or call order.
44
+ * - Same length: `result.length === secret.length` (UTF-16 code units, i.e.
45
+ * `String.prototype.length` semantics — unchanged from the pre-fix
46
+ * implementation). Astral-plane characters count as two units, exactly as
47
+ * they did before.
48
+ * - Allowed characters: output is restricted to `[A-Za-z0-9]` (62 chars).
49
+ * - Explicit `replacement` values in secrets.yml are authoritative and are
50
+ * never derived; this path runs only when `replacement` is undefined.
51
+ * - Replace mode stays one-way: the derived value is never reversed by
52
+ * `deobfuscate()`.
53
+ *
54
+ * THREAT MODEL (issue #4166):
55
+ * - Attacker model: an observer sees one or more replacements (model context,
56
+ * provider-side logs, saved/shared transcripts) and has full knowledge of
57
+ * the public algorithm. Without the 32-byte obfuscation key they cannot
58
+ * confirm a candidate secret offline and cannot predict or precompute the
59
+ * replacement for any secret. The construction is a keyed PRF
60
+ * (HMAC-SHA256), so output is unpredictable without the key, and keyed
61
+ * domain separation keeps this construction distinct from the placeholder
62
+ * construction.
63
+ * - Accepted residual disclosure: the replacement is same-length by design
64
+ * (required behavior above), so the secret's exact length is still
65
+ * observable. This is inherent to size-preserving substitution and is
66
+ * explicitly out of scope; the fix removes the *keyless confirmation*
67
+ * oracle, not the length signal.
68
+ * - Cross-process stability / key rotation: the process key is generated per
69
+ * process (`PROCESS_SECRET_OBFUSCATION_KEY`), so derived replacements are
70
+ * stable within a process and differ across processes or after a key
71
+ * rotation. This is a deliberate decision, identical in scope to the
72
+ * obfuscate-mode placeholder behavior. Users who need stable values across
73
+ * processes or restarts must set an explicit `replacement` in secrets.yml.
74
+ * - Collision/bias: output is a keyed pseudorandom mapping. Two distinct
75
+ * secrets can in principle collide (birthday bound over the 62^len output
76
+ * space); for realistic secret lengths this is negligible. Character
77
+ * distribution is uniform by construction: digest bytes are rejection-
78
+ * sampled (bytes 248-255 rejected, 256 - (256 % 62) = 248 = 62 * 4), so
79
+ * every character is exactly equally likely, with no modulo bias.
80
+ * - Empty secrets: a zero-length secret yields an empty replacement and the
81
+ * obfuscator treats it as a no-op (load-time validation rejects empty
82
+ * content anyway). Unicode secrets are hashed as UTF-8 bytes, so encoding
83
+ * is canonical. Very long secrets expand via a counter-based HMAC stream
84
+ * in O(length) blocks; generation cost is linear in the secret length.
85
+ * - Overlapping secrets and streaming: derivation is per-secret and does not
86
+ * depend on matching semantics (longest-first overlap handling is
87
+ * unchanged), and `obfuscate()` runs per complete text payload, so chunked
88
+ * output (e.g. streamed LLM messages) sees the same deterministic
89
+ * replacement for the same secret within a process.
90
+ */
91
+ function generateDeterministicReplacement(secret: string, key: Uint8Array): string {
92
+ const length = secret.length;
93
+ if (length === 0) return "";
28
94
  const chars: string[] = [];
29
- let h = hash;
30
- for (let i = 0; i < secret.length; i++) {
31
- // Mix the hash for each character position
32
- h = h ^ (BigInt(i + 1) * 0x9e3779b97f4a7c15n);
33
- const idx = Number((h < 0n ? -h : h) % BigInt(REPLACEMENT_CHARS.length));
34
- chars.push(REPLACEMENT_CHARS[idx]);
95
+ // CTR-style expansion: HMAC(key, DOMAIN || counter || secret) per 32-byte
96
+ // block. The counter is big-endian and fixed-width, so the stream is
97
+ // unambiguous and never repeats.
98
+ const counter = new Uint8Array(8);
99
+ const counterView = new DataView(counter.buffer);
100
+ let block: Uint8Array | undefined;
101
+ let blockOffset = REPLACEMENT_DIGEST_BYTES;
102
+ let blockIndex = 0;
103
+ while (chars.length < length) {
104
+ if (block === undefined || blockOffset >= REPLACEMENT_DIGEST_BYTES) {
105
+ counterView.setUint32(4, blockIndex, false);
106
+ block = createHmac("sha256", key).update(REPLACEMENT_DOMAIN).update(counter).update(secret, "utf8").digest();
107
+ blockOffset = 0;
108
+ blockIndex++;
109
+ }
110
+ const byte = block[blockOffset++]!;
111
+ if (byte >= REPLACEMENT_REJECT_THRESHOLD) continue;
112
+ chars.push(REPLACEMENT_CHARS[byte % REPLACEMENT_CHARS.length]!);
35
113
  }
36
114
  return chars.join("");
37
115
  }
@@ -115,7 +193,8 @@ export class SecretObfuscator {
115
193
  index++;
116
194
  } else {
117
195
  // replace mode
118
- const replacement = entry.replacement ?? generateDeterministicReplacement(entry.content);
196
+ const replacement =
197
+ entry.replacement ?? generateDeterministicReplacement(entry.content, this.#placeholderKey);
119
198
  this.#replaceMappings.set(entry.content, replacement);
120
199
  }
121
200
  } else {
@@ -168,7 +247,8 @@ export class SecretObfuscator {
168
247
 
169
248
  for (const matchValue of matches) {
170
249
  if (entry.mode === "replace") {
171
- const replacement = entry.replacement ?? generateDeterministicReplacement(matchValue);
250
+ const replacement =
251
+ entry.replacement ?? generateDeterministicReplacement(matchValue, this.#placeholderKey);
172
252
  result = replaceAll(result, matchValue, replacement);
173
253
  } else {
174
254
  // obfuscate mode — get or create stable index