@gajae-code/coding-agent 0.18.12 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +467 -0
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +7 -2
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +8 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +23 -1
- package/dist/types/config/config-hot-reload.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +44 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -5
- package/dist/types/config/model-registry.d.ts +16 -3
- package/dist/types/config/model-resolver.d.ts +5 -4
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +9 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +1 -1
- package/dist/types/migrate/adapters/index.d.ts +35 -3
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +69 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +22 -1
- package/dist/types/sdk/broker/ensure.d.ts +5 -2
- package/dist/types/sdk/broker/hop.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/session-index.d.ts +2 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/index.d.ts +21 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +8 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/session/agent-session.d.ts +50 -8
- package/dist/types/session/session-manager.d.ts +16 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +13 -2
- package/dist/types/tools/fetch.d.ts +6 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/package.json +9 -8
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/generate-sdk-operation-inventory.ts +16 -0
- package/src/capability/fs.ts +31 -3
- package/src/capability/rule.ts +11 -2
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/fast-help.ts +0 -2
- package/src/cli/migrate-cli.ts +14 -3
- package/src/cli/public-command-errors.ts +2 -0
- package/src/cli/root-flags.ts +0 -2
- package/src/cli/update-cli.ts +247 -6
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +81 -11
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +20 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload.ts +8 -0
- package/src/config/file-lock.ts +1431 -304
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-profile-activation.ts +177 -39
- package/src/config/model-registry.ts +380 -55
- package/src/config/model-resolver.ts +49 -21
- package/src/config/settings-schema.ts +13 -1
- package/src/config/settings.ts +2 -0
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +3 -1
- package/src/customization/import.ts +276 -7
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +87 -5
- package/src/debug/report-bundle.ts +71 -2
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +24 -14
- package/src/edit/index.ts +191 -5
- package/src/edit/modes/patch.ts +27 -0
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +60 -20
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +4 -1
- package/src/gjc-runtime/launch-tmux.ts +23 -13
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +3 -1
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +14 -14
- package/src/internal-urls/local-protocol.ts +28 -3
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +517 -2
- package/src/migrate/adapters/claude-code.ts +141 -3
- package/src/migrate/adapters/codex.ts +57 -5
- package/src/migrate/adapters/index.ts +240 -5
- package/src/migrate/adapters/opencode.ts +4 -0
- package/src/migrate/executor.ts +141 -3
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +45 -1
- package/src/migrate/types.ts +73 -3
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +271 -16
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +49 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +14 -1
- package/src/modes/interactive-mode.ts +55 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/runtime-mcp/manager.ts +8 -3
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/tool-bridge.ts +68 -4
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +2 -0
- package/src/sdk/broker/broker.ts +143 -4
- package/src/sdk/broker/ensure.ts +74 -6
- package/src/sdk/broker/hop.ts +13 -1
- package/src/sdk/broker/lifecycle-ledger.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/session-index.ts +29 -4
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +408 -14
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1537 -24
- package/src/sdk/host/session-runtime.ts +332 -36
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +14 -1
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +28 -1
- package/src/sdk/protocol/operation-inventory.generated.json +160 -0
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/session.ts +123 -36
- package/src/session/agent-session.ts +1614 -329
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +47 -18
- package/src/session/internal/managed-session-storage.ts +71 -1
- package/src/session/session-manager.ts +90 -5
- package/src/session-import/redact.ts +53 -2
- package/src/skill-state/workflow-mutation-guard.ts +1498 -18
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +12 -8
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/auto-generated-guard.ts +12 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +61 -1
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +99 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +9 -2
- package/src/tools/descriptors.test.ts +4 -4
- package/src/tools/descriptors.ts +16 -4
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +90 -2
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +51 -3
- package/src/tools/read.ts +9 -2
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent.ts +10 -4
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +7 -0
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +3 -2
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +46 -6
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +65 -20
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
package/src/discovery/helpers.ts
CHANGED
|
@@ -202,14 +202,24 @@ export function getProjectPath(ctx: LoadContext, source: SourceId, subpath: stri
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/** Build the filesystem authority for a provider read. */
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
205
|
+
type DiscoveryReadContext = Pick<
|
|
206
|
+
LoadContext,
|
|
207
|
+
"home" | "isolatedHome" | "userAgentDir" | "homeIdentity" | "userAgentIdentity" | "bypassCache"
|
|
208
|
+
> & {
|
|
209
|
+
repoRoot?: string | null;
|
|
210
|
+
cwd?: string;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export function getReadOptions(ctx: DiscoveryReadContext, scope: ReadScope): ReadFileOptions | undefined {
|
|
214
|
+
if (!ctx.isolatedHome) {
|
|
215
|
+
const boundary = scope === "project" ? (ctx.repoRoot ?? ctx.cwd) : undefined;
|
|
216
|
+
if (!boundary) return ctx.bypassCache ? { bypassCache: true } : undefined;
|
|
217
|
+
return {
|
|
218
|
+
...(ctx.bypassCache ? { bypassCache: true as const } : {}),
|
|
219
|
+
containmentRoot: boundary,
|
|
220
|
+
scope,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
213
223
|
return {
|
|
214
224
|
isolatedHome: true,
|
|
215
225
|
home: ctx.home,
|
|
@@ -222,15 +232,14 @@ export function getReadOptions(
|
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
export async function getReadOptionsForContainment(
|
|
225
|
-
ctx:
|
|
226
|
-
LoadContext,
|
|
227
|
-
"home" | "isolatedHome" | "userAgentDir" | "homeIdentity" | "userAgentIdentity" | "bypassCache"
|
|
228
|
-
>,
|
|
235
|
+
ctx: DiscoveryReadContext,
|
|
229
236
|
scope: ReadScope,
|
|
230
237
|
containmentRoot?: string,
|
|
231
238
|
): Promise<ReadFileOptions | undefined> {
|
|
232
239
|
const options = getReadOptions(ctx, scope);
|
|
233
|
-
|
|
240
|
+
// Ordinary reads keep the repository boundary from getReadOptions. The extra
|
|
241
|
+
// root is an isolated-home constraint and was not applied on this path before.
|
|
242
|
+
if (!ctx.isolatedHome || !options || !containmentRoot) return options;
|
|
234
243
|
const containmentRootIdentity = await capturePathIdentity(containmentRoot);
|
|
235
244
|
if (!containmentRootIdentity) return undefined;
|
|
236
245
|
return { ...options, containmentRoot, containmentRootIdentity };
|
|
@@ -298,7 +307,7 @@ export function buildRuleFromMarkdown(
|
|
|
298
307
|
},
|
|
299
308
|
): Rule {
|
|
300
309
|
const { frontmatter, body } = parseFrontmatter(content, { source: filePath });
|
|
301
|
-
const { condition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter);
|
|
310
|
+
const { condition, astCondition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter);
|
|
302
311
|
|
|
303
312
|
let globs: string[] | undefined;
|
|
304
313
|
if (Array.isArray(frontmatter.globs)) {
|
|
@@ -329,6 +338,7 @@ export function buildRuleFromMarkdown(
|
|
|
329
338
|
alwaysApply: frontmatter.alwaysApply === true,
|
|
330
339
|
description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
|
|
331
340
|
condition,
|
|
341
|
+
astCondition,
|
|
332
342
|
scope,
|
|
333
343
|
interruptMode,
|
|
334
344
|
repeatMode,
|
package/src/edit/index.ts
CHANGED
|
@@ -3,9 +3,12 @@ import { prompt } from "@gajae-code/utils";
|
|
|
3
3
|
import type * as z from "zod/v4";
|
|
4
4
|
import {
|
|
5
5
|
executeHashlineSingle,
|
|
6
|
+
type HashlineEdit,
|
|
6
7
|
HashlineMismatchError,
|
|
7
8
|
type HashlineParams,
|
|
8
9
|
hashlineEditParamsSchema,
|
|
10
|
+
parseHashline,
|
|
11
|
+
splitHashlineInputs,
|
|
9
12
|
} from "../hashline";
|
|
10
13
|
import hashlineGrammarTemplate from "../hashline/grammar.lark" with { type: "text" };
|
|
11
14
|
import { resolveHashlineGrammarPlaceholders } from "../hashline/hash";
|
|
@@ -21,12 +24,27 @@ import hashlineDescription from "../prompts/tools/hashline.md" with { type: "tex
|
|
|
21
24
|
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
|
|
22
25
|
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
|
|
23
26
|
import type { ToolSession } from "../tools";
|
|
27
|
+
import { assertEditableFile } from "../tools/auto-generated-guard";
|
|
28
|
+
import { resolvePlanPath } from "../tools/plan-mode-guard";
|
|
24
29
|
import { VimTool, vimSchema } from "../tools/vim";
|
|
25
30
|
import { type EditMode, resolveEditMode, resolveForcedEnvEditMode } from "../utils/edit-mode";
|
|
26
31
|
import type { VimToolDetails } from "../vim/types";
|
|
27
|
-
import {
|
|
32
|
+
import { normalizeCreateContent } from "./diff";
|
|
33
|
+
import {
|
|
34
|
+
type ApplyPatchEntry,
|
|
35
|
+
type ApplyPatchParams,
|
|
36
|
+
applyPatchSchema,
|
|
37
|
+
expandApplyPatchToEntries,
|
|
38
|
+
expandApplyPatchToPreviewEntries,
|
|
39
|
+
} from "./modes/apply-patch";
|
|
28
40
|
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
|
|
29
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
executePatchSingle,
|
|
43
|
+
type PatchEditEntry,
|
|
44
|
+
type PatchParams,
|
|
45
|
+
patchEditEntrySchema,
|
|
46
|
+
patchEditSchema,
|
|
47
|
+
} from "./modes/patch";
|
|
30
48
|
import { executeReplaceEntries, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
|
|
31
49
|
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
|
|
32
50
|
|
|
@@ -57,6 +75,12 @@ type VimParams = z.infer<typeof vimSchema>;
|
|
|
57
75
|
type EditParams = ReplaceParams | PatchParams | HashlineParams | VimParams | ApplyPatchParams;
|
|
58
76
|
type EditToolResultDetails = EditToolDetails | VimToolDetails;
|
|
59
77
|
|
|
78
|
+
/** Diff bodies introduce only `+` rows, not unified-diff file headers. */
|
|
79
|
+
function addedPatchDigest(diff: string): string | undefined {
|
|
80
|
+
const added = diff.split("\n").filter(line => line.startsWith("+") && !line.startsWith("+++ "));
|
|
81
|
+
return added.length > 0 ? added.map(line => line.slice(1)).join("\n") : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
const UNSAFE_EDIT_SUMMARY_PATH =
|
|
61
85
|
/(?:\b(?:https?|wss?):\/\/|\b(?:api[-_ ]?key|access[-_ ]?token|bearer|secret|password)\b|\b(?:sk|pk|rk)-[A-Za-z0-9_-]{12,})/i;
|
|
62
86
|
|
|
@@ -167,6 +191,56 @@ function createEditWritethrough(session: ToolSession): WritethroughCallback {
|
|
|
167
191
|
return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop;
|
|
168
192
|
}
|
|
169
193
|
|
|
194
|
+
type PatchGuardFailure = { path: string; errorText: string };
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Refuse a generated source or rename destination before any entry writes.
|
|
198
|
+
* A single entry still throws. Several entries return the per-entry errors
|
|
199
|
+
* the patch aggregators already use, and the caller writes nothing.
|
|
200
|
+
*/
|
|
201
|
+
async function collectPatchGuardFailures(
|
|
202
|
+
session: ToolSession,
|
|
203
|
+
entries: { path: string; rename?: string }[],
|
|
204
|
+
signal?: AbortSignal,
|
|
205
|
+
): Promise<PatchGuardFailure[] | undefined> {
|
|
206
|
+
const failures: PatchGuardFailure[] = [];
|
|
207
|
+
const aggregate = entries.length > 1;
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
signal?.throwIfAborted();
|
|
210
|
+
try {
|
|
211
|
+
const resolvedPath = resolvePlanPath(session, entry.path);
|
|
212
|
+
await assertEditableFile(resolvedPath, entry.path, session.settings);
|
|
213
|
+
signal?.throwIfAborted();
|
|
214
|
+
if (!entry.rename) continue;
|
|
215
|
+
const resolvedRename = resolvePlanPath(session, entry.rename);
|
|
216
|
+
if (resolvedRename === resolvedPath) continue;
|
|
217
|
+
await assertEditableFile(resolvedRename, entry.rename, session.settings);
|
|
218
|
+
signal?.throwIfAborted();
|
|
219
|
+
} catch (err) {
|
|
220
|
+
signal?.throwIfAborted();
|
|
221
|
+
if (!aggregate || (err instanceof Error && err.name === "AbortError")) throw err;
|
|
222
|
+
failures.push({ path: entry.path, errorText: err instanceof Error ? err.message : String(err) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return failures.length > 0 ? failures : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function refusedPatchEntriesResult(
|
|
229
|
+
failures: PatchGuardFailure[],
|
|
230
|
+
details: { path?: string; perFileResults?: EditToolPerFileResult[] },
|
|
231
|
+
): AgentToolResult<EditToolDetails, TInput> {
|
|
232
|
+
return {
|
|
233
|
+
content: [
|
|
234
|
+
{
|
|
235
|
+
type: "text",
|
|
236
|
+
text: failures.map(failure => `Error editing ${failure.path}: ${failure.errorText}`).join("\n"),
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
details: { diff: "", ...details },
|
|
240
|
+
isError: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
170
244
|
/** Run apply_patch file operations and aggregate their multi-file result. */
|
|
171
245
|
async function executeApplyPatchPerFile(
|
|
172
246
|
fileEntries: {
|
|
@@ -364,6 +438,96 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
364
438
|
return this.#getModeDefinition().parameters;
|
|
365
439
|
}
|
|
366
440
|
|
|
441
|
+
matcherDigest(args: unknown): string | undefined {
|
|
442
|
+
const digest = this.matcherEntries(args)
|
|
443
|
+
?.map(entry => entry.digest)
|
|
444
|
+
.filter(Boolean)
|
|
445
|
+
.join("\n");
|
|
446
|
+
return digest || undefined;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
matcherEntries(args: unknown): readonly { path: string; digest: string }[] | undefined {
|
|
450
|
+
try {
|
|
451
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
|
|
452
|
+
const record = args as Record<string, unknown>;
|
|
453
|
+
const digests = new Map<string, string>();
|
|
454
|
+
const add = (path: string, digest: string | undefined) => {
|
|
455
|
+
if (!path || digest === undefined) return;
|
|
456
|
+
const current = digests.get(path);
|
|
457
|
+
digests.set(path, current === undefined ? digest : `${current}\n${digest}`);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
switch (this.mode) {
|
|
461
|
+
case "replace": {
|
|
462
|
+
if (typeof record.path !== "string" || !Array.isArray(record.edits)) return undefined;
|
|
463
|
+
for (const entry of record.edits) {
|
|
464
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
|
|
465
|
+
const text = (entry as Record<string, unknown>).new_text;
|
|
466
|
+
if (text !== undefined && typeof text !== "string") return undefined;
|
|
467
|
+
add(record.path, text);
|
|
468
|
+
}
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case "patch": {
|
|
472
|
+
if (typeof record.path !== "string" || !Array.isArray(record.edits)) return undefined;
|
|
473
|
+
for (const entry of record.edits) {
|
|
474
|
+
const parsed = patchEditEntrySchema.safeParse(entry);
|
|
475
|
+
if (!parsed.success) return undefined;
|
|
476
|
+
const { diff, op, rename } = parsed.data;
|
|
477
|
+
if (op === "delete" || diff === undefined) continue;
|
|
478
|
+
// A create body is digested exactly as the writer will persist it.
|
|
479
|
+
add(rename ?? record.path, op === "create" ? normalizeCreateContent(diff) : addedPatchDigest(diff));
|
|
480
|
+
}
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
case "hashline": {
|
|
484
|
+
const parsed = hashlineEditParamsSchema.safeParse(args);
|
|
485
|
+
if (!parsed.success) return undefined;
|
|
486
|
+
for (const section of splitHashlineInputs(parsed.data.input, {
|
|
487
|
+
path: parsed.data.path,
|
|
488
|
+
cwd: this.session.cwd,
|
|
489
|
+
})) {
|
|
490
|
+
// A section still streaming (e.g. a trailing op with no anchor yet)
|
|
491
|
+
// does not parse; it contributes nothing, but earlier complete
|
|
492
|
+
// sections keep their entries (as pi-edit's hashline inspect does).
|
|
493
|
+
let edits: HashlineEdit[];
|
|
494
|
+
try {
|
|
495
|
+
edits = parseHashline(section.diff);
|
|
496
|
+
} catch {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const inserted = edits.filter(edit => edit.kind === "insert");
|
|
500
|
+
if (inserted.length > 0) add(section.path, inserted.map(edit => edit.text).join("\n"));
|
|
501
|
+
}
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
case "apply_patch": {
|
|
505
|
+
const parsed = applyPatchSchema.safeParse(args);
|
|
506
|
+
if (!parsed.success) return undefined;
|
|
507
|
+
let entries: ApplyPatchEntry[];
|
|
508
|
+
try {
|
|
509
|
+
entries = expandApplyPatchToEntries(parsed.data);
|
|
510
|
+
} catch {
|
|
511
|
+
entries = expandApplyPatchToPreviewEntries(parsed.data);
|
|
512
|
+
}
|
|
513
|
+
for (const entry of entries) {
|
|
514
|
+
if (entry.op === "delete" || entry.diff === undefined) continue;
|
|
515
|
+
// The envelope parser already strips Add File prefixes.
|
|
516
|
+
add(entry.rename ?? entry.path, entry.op === "create" ? entry.diff : addedPatchDigest(entry.diff));
|
|
517
|
+
}
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
case "vim":
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return digests.size > 0 ? Array.from(digests, ([path, digest]) => ({ path, digest })) : undefined;
|
|
525
|
+
} catch {
|
|
526
|
+
// Streamed arguments can be incomplete or malformed in any mode.
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
367
531
|
/**
|
|
368
532
|
* When in `apply_patch` mode, expose the OpenAI code backend Lark grammar so providers
|
|
369
533
|
* that support OpenAI-style custom tools can emit a grammar-constrained
|
|
@@ -403,7 +567,7 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
403
567
|
patch: {
|
|
404
568
|
description: () => prompt.render(patchDescription),
|
|
405
569
|
parameters: patchEditSchema,
|
|
406
|
-
execute: (
|
|
570
|
+
execute: async (
|
|
407
571
|
tool: EditTool,
|
|
408
572
|
params: EditParams,
|
|
409
573
|
signal: AbortSignal | undefined,
|
|
@@ -411,7 +575,14 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
411
575
|
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
412
576
|
) => {
|
|
413
577
|
const { edits, path } = params as PatchParams;
|
|
414
|
-
const
|
|
578
|
+
const patchEdits = edits as PatchEditEntry[];
|
|
579
|
+
const guardFailures = await collectPatchGuardFailures(
|
|
580
|
+
tool.session,
|
|
581
|
+
patchEdits.map(entry => ({ path, rename: entry.rename })),
|
|
582
|
+
signal,
|
|
583
|
+
);
|
|
584
|
+
if (guardFailures) return refusedPatchEntriesResult(guardFailures, { path });
|
|
585
|
+
const runs = patchEdits.map(
|
|
415
586
|
entry => (br: LspBatchRequest | undefined) =>
|
|
416
587
|
executePatchSingle({
|
|
417
588
|
session: tool.session,
|
|
@@ -431,7 +602,7 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
431
602
|
apply_patch: {
|
|
432
603
|
description: () => prompt.render(applyPatchDescription),
|
|
433
604
|
parameters: applyPatchSchema,
|
|
434
|
-
execute: (
|
|
605
|
+
execute: async (
|
|
435
606
|
tool: EditTool,
|
|
436
607
|
params: EditParams,
|
|
437
608
|
signal: AbortSignal | undefined,
|
|
@@ -439,6 +610,21 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
439
610
|
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
440
611
|
) => {
|
|
441
612
|
const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
|
|
613
|
+
const guardFailures = await collectPatchGuardFailures(
|
|
614
|
+
tool.session,
|
|
615
|
+
entries.map(entry => ({ path: entry.path, rename: entry.rename })),
|
|
616
|
+
signal,
|
|
617
|
+
);
|
|
618
|
+
if (guardFailures) {
|
|
619
|
+
return refusedPatchEntriesResult(guardFailures, {
|
|
620
|
+
perFileResults: guardFailures.map(failure => ({
|
|
621
|
+
path: failure.path,
|
|
622
|
+
diff: "",
|
|
623
|
+
isError: true,
|
|
624
|
+
errorText: failure.errorText,
|
|
625
|
+
})),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
442
628
|
const perFile = entries.map(entry => {
|
|
443
629
|
const { path, ...patchParams } = entry;
|
|
444
630
|
return {
|
package/src/edit/modes/patch.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as path from "node:path";
|
|
|
11
11
|
import type { AgentToolResult } from "@gajae-code/agent-core";
|
|
12
12
|
import { isEnoent } from "@gajae-code/utils";
|
|
13
13
|
import * as z from "zod/v4";
|
|
14
|
+
import type { Settings } from "../../config/settings";
|
|
14
15
|
import {
|
|
15
16
|
type FileDiagnosticsResult,
|
|
16
17
|
flushLspWritethroughBatch,
|
|
@@ -84,6 +85,14 @@ export interface ApplyPatchOptions {
|
|
|
84
85
|
/** Explicit artifacts `local` root for this call. The caller session owns it. */
|
|
85
86
|
localRoot?: string;
|
|
86
87
|
signal?: AbortSignal;
|
|
88
|
+
/**
|
|
89
|
+
* Recheck a rename destination while this call holds the path mutation lock.
|
|
90
|
+
* The request preflight runs before that lock.
|
|
91
|
+
*/
|
|
92
|
+
destinationGuard?: {
|
|
93
|
+
settings: Pick<Settings, "get">;
|
|
94
|
+
displayPath: string;
|
|
95
|
+
};
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -444,6 +453,13 @@ async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOption
|
|
|
444
453
|
throw new ApplyPatchError(`concurrent edit conflict: file changed since read: ${input.path}`);
|
|
445
454
|
}
|
|
446
455
|
if (isMove) {
|
|
456
|
+
// Preflight can pass, then another writer holding this lock can
|
|
457
|
+
// turn a handwritten destination into a generated file.
|
|
458
|
+
if (options.destinationGuard) {
|
|
459
|
+
signal?.throwIfAborted();
|
|
460
|
+
await assertEditableFile(destPath, options.destinationGuard.displayPath, options.destinationGuard.settings);
|
|
461
|
+
signal?.throwIfAborted();
|
|
462
|
+
}
|
|
447
463
|
if (moveParent) {
|
|
448
464
|
signal?.throwIfAborted();
|
|
449
465
|
await fs.mkdir(moveParent);
|
|
@@ -643,7 +659,15 @@ export async function executePatchSingle(
|
|
|
643
659
|
const resolvedPath = resolvePlanPath(session, path);
|
|
644
660
|
const resolvedRename = rename ? resolvePlanPath(session, rename) : undefined;
|
|
645
661
|
|
|
662
|
+
signal?.throwIfAborted();
|
|
646
663
|
await assertEditableFile(resolvedPath, path, session.settings);
|
|
664
|
+
// The rename destination is the path that is written. The source call above
|
|
665
|
+
// already uses this helper, including generated names that do not exist yet.
|
|
666
|
+
signal?.throwIfAborted();
|
|
667
|
+
if (resolvedRename && rename && resolvedRename !== resolvedPath) {
|
|
668
|
+
await assertEditableFile(resolvedRename, rename, session.settings);
|
|
669
|
+
signal?.throwIfAborted();
|
|
670
|
+
}
|
|
647
671
|
|
|
648
672
|
// Capture pre-edit content so we can verify the write actually hit disk.
|
|
649
673
|
// `LspFileSystem.writeFile` delegates to a writethrough callback that, in
|
|
@@ -678,6 +702,9 @@ export async function executePatchSingle(
|
|
|
678
702
|
crossProcessLock: true,
|
|
679
703
|
localRoot,
|
|
680
704
|
signal,
|
|
705
|
+
...(op === "update" && resolvedRename && rename && resolvedRename !== resolvedPath
|
|
706
|
+
? { destinationGuard: { settings: session.settings, displayPath: rename } }
|
|
707
|
+
: {}),
|
|
681
708
|
});
|
|
682
709
|
|
|
683
710
|
// Post-write verification: only meaningful for in-place updates where the
|
package/src/eval/py/runtime.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
-
import { $env, $which, getPythonEnvDir, WhichCachePolicy } from "@gajae-code/utils";
|
|
10
|
+
import { $env, $which, getPythonEnvDir, relativePathEscapesRoot, WhichCachePolicy } from "@gajae-code/utils";
|
|
11
11
|
import { withFileLock } from "../../config/file-lock";
|
|
12
|
+
import { isProjectControlledPath } from "../../lsp/path-trust";
|
|
12
13
|
|
|
13
14
|
export const RLM_MANAGED_PYTHON_PACKAGES: readonly string[] = ["numpy", "pandas", "matplotlib", "polars"];
|
|
14
15
|
|
|
@@ -133,6 +134,47 @@ function resolvePythonCandidateInVenv(venvPath: string): { venvPath: string; pyt
|
|
|
133
134
|
return { venvPath, pythonPath, binDir };
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
/**
|
|
138
|
+
* A workspace `.venv` or `venv` directory is repository content. Use it only when
|
|
139
|
+
* it is a real virtualenv (`pyvenv.cfg`) whose interpreter file resolves outside
|
|
140
|
+
* the project. An in-tree script or binary is not an interpreter.
|
|
141
|
+
*
|
|
142
|
+
* True when `candidate` is `root` or a descendant after `path.resolve`.
|
|
143
|
+
* A name such as `..payload` stays inside. Only `..` and a parent segment escape.
|
|
144
|
+
*/
|
|
145
|
+
export function workspacePathContained(root: string, candidate: string): boolean {
|
|
146
|
+
const normalize = (value: string): string => {
|
|
147
|
+
const resolved = path.resolve(value);
|
|
148
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
149
|
+
};
|
|
150
|
+
return !relativePathEscapesRoot(path.relative(normalize(root), normalize(candidate)));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isAutoDetectedWorkspaceVenv(candidate: string, cwd: string): boolean {
|
|
154
|
+
try {
|
|
155
|
+
if (!fs.statSync(path.join(candidate, "pyvenv.cfg")).isFile()) return false;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
let canonicalPython: string;
|
|
160
|
+
try {
|
|
161
|
+
canonicalPython = fs.realpathSync(resolvePythonCandidateInVenv(candidate).pythonPath);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
// HOME stops project-root discovery, so a workspace that is the home directory
|
|
166
|
+
// is not project-controlled. Refuse an interpreter that still resolves inside
|
|
167
|
+
// cwd, including a file whose name is `..payload`.
|
|
168
|
+
let canonicalCwd = path.resolve(cwd);
|
|
169
|
+
try {
|
|
170
|
+
canonicalCwd = fs.realpathSync(cwd);
|
|
171
|
+
} catch {
|
|
172
|
+
// Missing cwd keeps the resolved path.
|
|
173
|
+
}
|
|
174
|
+
if (workspacePathContained(canonicalCwd, canonicalPython)) return false;
|
|
175
|
+
return !isProjectControlledPath(canonicalPython, cwd);
|
|
176
|
+
}
|
|
177
|
+
|
|
136
178
|
function resolveManagedPythonCandidate(): { venvPath: string; pythonPath: string; binDir: string } {
|
|
137
179
|
return resolvePythonCandidateInVenv(resolveGlobalManagedPythonEnv());
|
|
138
180
|
}
|
|
@@ -197,9 +239,7 @@ export function resolveVenvPath(cwd: string): string | undefined {
|
|
|
197
239
|
if ($env.CONDA_PREFIX) return $env.CONDA_PREFIX;
|
|
198
240
|
const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
|
|
199
241
|
for (const candidate of candidates) {
|
|
200
|
-
if (
|
|
201
|
-
return candidate;
|
|
202
|
-
}
|
|
242
|
+
if (isAutoDetectedWorkspaceVenv(candidate, cwd)) return candidate;
|
|
203
243
|
}
|
|
204
244
|
return undefined;
|
|
205
245
|
}
|
|
@@ -24,7 +24,6 @@ export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
|
|
|
24
24
|
EDITOR: "true",
|
|
25
25
|
GIT_TERMINAL_PROMPT: "0",
|
|
26
26
|
SSH_ASKPASS: "/usr/bin/false",
|
|
27
|
-
CI: "1",
|
|
28
27
|
// Package manager defaults for unattended execution.
|
|
29
28
|
npm_config_yes: "true",
|
|
30
29
|
npm_config_update_notifier: "false",
|
package/src/export/html/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as syncFs from "node:fs";
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentState } from "@gajae-code/agent-core";
|
|
5
|
-
import { APP_NAME, isEnoent, redactCrashSecrets } from "@gajae-code/utils";
|
|
5
|
+
import { APP_NAME, isCredentialKey, isEnoent, redactCrashSecrets } from "@gajae-code/utils";
|
|
6
6
|
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/theme";
|
|
7
7
|
import { SessionManager } from "../../session/session-manager";
|
|
8
8
|
// Pre-generated template (created by scripts/generate-template.ts at publish time)
|
|
@@ -255,38 +255,78 @@ async function writeSessionHtml(
|
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
257
|
* Own `__proto__` stays a data property; a normal object would turn that assignment into the prototype.
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* redacted on its own so a quotation mark inside a labeled secret cannot break the export.
|
|
258
|
+
* Credential keys mask every JSON value, and string values are scrubbed directly so JSON escaping
|
|
259
|
+
* cannot turn ordinary quote characters into credential-value delimiters.
|
|
261
260
|
*/
|
|
262
261
|
function redactJsonValue(value: unknown): unknown {
|
|
263
262
|
if (typeof value === "string") return redactCrashSecrets(value);
|
|
264
|
-
if (Array.isArray(value))
|
|
263
|
+
if (Array.isArray(value)) {
|
|
264
|
+
const isFlatKeyValueArray =
|
|
265
|
+
value.length > 0 &&
|
|
266
|
+
value.length % 2 === 0 &&
|
|
267
|
+
value.every((item, index) => index % 2 === 1 || typeof item === "string");
|
|
268
|
+
if (isFlatKeyValueArray) {
|
|
269
|
+
return value.map((item, index) => {
|
|
270
|
+
if (index % 2 === 1) {
|
|
271
|
+
const key = value[index - 1];
|
|
272
|
+
if (typeof key === "string" && isCredentialKey(key)) return "«redacted»";
|
|
273
|
+
}
|
|
274
|
+
return redactJsonValue(item);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return value.map(item => redactJsonValue(item));
|
|
278
|
+
}
|
|
265
279
|
if (value === null || typeof value !== "object") return value;
|
|
266
280
|
const source = value as Record<string, unknown>;
|
|
281
|
+
const sourceKeys = Object.keys(source);
|
|
282
|
+
let hasCredentialHeaderName = false;
|
|
283
|
+
let hasHeaderValue = false;
|
|
284
|
+
for (const key of sourceKeys) {
|
|
285
|
+
const field = key.toLowerCase();
|
|
286
|
+
if (field === "value" || field === "values") hasHeaderValue = true;
|
|
287
|
+
if (field !== "name" && field !== "key") continue;
|
|
288
|
+
const headerName = source[key];
|
|
289
|
+
if (typeof headerName === "string" && isCredentialKey(headerName)) hasCredentialHeaderName = true;
|
|
290
|
+
}
|
|
291
|
+
const redactHeaderValue = hasCredentialHeaderName && hasHeaderValue;
|
|
267
292
|
const redacted = Object.create(null) as Record<string, unknown>;
|
|
268
|
-
|
|
293
|
+
const nextSuffixByKey = new Map<string, number>();
|
|
294
|
+
for (const key of sourceKeys) {
|
|
295
|
+
const redactedKey = uniqueJsonObjectKey(redacted, redactCrashSecrets(key), nextSuffixByKey);
|
|
296
|
+
const field = key.toLowerCase();
|
|
297
|
+
if (redactHeaderValue && (field === "value" || field === "values")) {
|
|
298
|
+
const headerValue = source[key];
|
|
299
|
+
redacted[redactedKey] =
|
|
300
|
+
field === "values" && Array.isArray(headerValue) ? headerValue.map(() => "«redacted»") : "«redacted»";
|
|
301
|
+
} else {
|
|
302
|
+
redacted[redactedKey] = redactKeyedValue(key, source[key]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
269
305
|
return redacted;
|
|
270
306
|
}
|
|
271
307
|
|
|
272
|
-
|
|
273
|
-
|
|
308
|
+
// Resume after the last collision for this base key rather than rescanning prior suffixes.
|
|
309
|
+
function uniqueJsonObjectKey(
|
|
310
|
+
target: Record<string, unknown>,
|
|
311
|
+
key: string,
|
|
312
|
+
nextSuffixByKey: Map<string, number>,
|
|
313
|
+
): string {
|
|
314
|
+
if (!Object.hasOwn(target, key)) return key;
|
|
315
|
+
let suffix = nextSuffixByKey.get(key) ?? 2;
|
|
316
|
+
let candidate = `${key}#${suffix}`;
|
|
317
|
+
while (Object.hasOwn(target, candidate)) {
|
|
318
|
+
suffix += 1;
|
|
319
|
+
candidate = `${key}#${suffix}`;
|
|
320
|
+
}
|
|
321
|
+
nextSuffixByKey.set(key, suffix + 1);
|
|
322
|
+
return candidate;
|
|
323
|
+
}
|
|
274
324
|
|
|
275
325
|
function redactKeyedValue(key: string, value: unknown): unknown {
|
|
276
326
|
// A recognized key is authoritative for every JSON type, including a short
|
|
277
327
|
// string, a number, an array, or a nested object.
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
const encoded = JSON.stringify({ [key]: value });
|
|
281
|
-
try {
|
|
282
|
-
const parsed = JSON.parse(redactCrashSecrets(encoded)) as Record<string, unknown>;
|
|
283
|
-
if (parsed !== null && typeof parsed === "object" && Object.hasOwn(parsed, key)) return parsed[key];
|
|
284
|
-
} catch {
|
|
285
|
-
// A quote inside a credential field can make the redacted JSON invalid.
|
|
286
|
-
// Mask that field instead of exporting the raw value without its key.
|
|
287
|
-
if (CREDENTIAL_JSON_KEY.test(key)) return "«redacted»";
|
|
288
|
-
}
|
|
289
|
-
return redactCrashSecrets(value);
|
|
328
|
+
if (isCredentialKey(key)) return "«redacted»";
|
|
329
|
+
return typeof value === "string" ? redactCrashSecrets(value) : redactJsonValue(value);
|
|
290
330
|
}
|
|
291
331
|
|
|
292
332
|
function sessionEmbedJson(value: unknown): string {
|