@visual-z/xeno 0.8.4 → 0.9.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 +8 -0
- package/README.md +31 -746
- package/bin/xeno.exe +26 -0
- package/install.cjs +88 -0
- package/package.json +18 -48
- package/docs/app-server.md +0 -115
- package/docs/foundations.md +0 -88
- package/docs/install.md +0 -244
- package/docs/providers.md +0 -172
- package/docs/research/foundation-clients-2026-10-09.md +0 -27
- package/docs/research/foundation-editing-2026-10-09.md +0 -56
- package/docs/research/foundation-network-2026-10-09.md +0 -37
- package/docs/research/foundation-workflow-2026-10-09.md +0 -37
- package/src/apps/acp-server.ts +0 -41
- package/src/apps/agent-profiles.ts +0 -19
- package/src/apps/app-server.ts +0 -30
- package/src/apps/approve.ts +0 -49
- package/src/apps/cli.ts +0 -99
- package/src/apps/commands.ts +0 -319
- package/src/apps/completions.ts +0 -87
- package/src/apps/control-transport.ts +0 -90
- package/src/apps/export-cli.ts +0 -24
- package/src/apps/goal-flow.ts +0 -70
- package/src/apps/image-draft.ts +0 -46
- package/src/apps/input.ts +0 -312
- package/src/apps/model-catalog.ts +0 -69
- package/src/apps/preferences.ts +0 -20
- package/src/apps/project-trust.ts +0 -9
- package/src/apps/provider-connect.ts +0 -346
- package/src/apps/question-ui.ts +0 -27
- package/src/apps/render.ts +0 -181
- package/src/apps/resources.ts +0 -66
- package/src/apps/review.ts +0 -109
- package/src/apps/session-hub.ts +0 -96
- package/src/apps/session-lifecycle.ts +0 -64
- package/src/apps/shell.ts +0 -2071
- package/src/apps/startup-input.ts +0 -135
- package/src/apps/startup.ts +0 -1677
- package/src/apps/ui/app.ts +0 -1818
- package/src/apps/ui/control-model.ts +0 -43
- package/src/apps/ui/extension-host.ts +0 -194
- package/src/apps/ui/image-cache.ts +0 -121
- package/src/apps/ui/palette.ts +0 -88
- package/src/apps/ui/project.ts +0 -127
- package/src/apps/ui/standalone.ts +0 -148
- package/src/apps/ui/status-model.ts +0 -68
- package/src/apps/ui/surface.ts +0 -91
- package/src/apps/ui/theme.ts +0 -86
- package/src/apps/update-check.ts +0 -112
- package/src/apps/workflow.ts +0 -58
- package/src/core/acceptance.ts +0 -139
- package/src/core/action-defaults.ts +0 -58
- package/src/core/activity-status.ts +0 -52
- package/src/core/agent-profile.ts +0 -130
- package/src/core/attachment-input.ts +0 -17
- package/src/core/blind-checks.ts +0 -72
- package/src/core/boundary.ts +0 -174
- package/src/core/branch-receipt.ts +0 -26
- package/src/core/catalog-merge.ts +0 -19
- package/src/core/checkpoints.ts +0 -85
- package/src/core/churn.ts +0 -61
- package/src/core/command-artifacts.ts +0 -94
- package/src/core/command-contract.ts +0 -151
- package/src/core/completion-action.ts +0 -20
- package/src/core/completion-review.ts +0 -98
- package/src/core/context-detail.ts +0 -37
- package/src/core/context-policy.ts +0 -133
- package/src/core/context-window.ts +0 -54
- package/src/core/continuation.ts +0 -130
- package/src/core/control-protocol.ts +0 -69
- package/src/core/control-view.ts +0 -39
- package/src/core/cycle.ts +0 -17
- package/src/core/data-query.ts +0 -201
- package/src/core/deadline.ts +0 -40
- package/src/core/derive.ts +0 -516
- package/src/core/draft-images.ts +0 -56
- package/src/core/edit-plan.ts +0 -200
- package/src/core/environment.ts +0 -181
- package/src/core/event-validation.ts +0 -220
- package/src/core/events.ts +0 -670
- package/src/core/evidence-ladder.ts +0 -62
- package/src/core/evidence-links.ts +0 -68
- package/src/core/evidence.ts +0 -240
- package/src/core/execution.ts +0 -18
- package/src/core/exhaustive.ts +0 -18
- package/src/core/export.ts +0 -179
- package/src/core/extension-flags.ts +0 -14
- package/src/core/external-effects.ts +0 -488
- package/src/core/failure-recovery.ts +0 -173
- package/src/core/file-precondition.ts +0 -78
- package/src/core/file-references.ts +0 -35
- package/src/core/frontmatter.ts +0 -213
- package/src/core/git-status.ts +0 -29
- package/src/core/goal-view.ts +0 -64
- package/src/core/goal.ts +0 -200
- package/src/core/handoff.ts +0 -479
- package/src/core/history-query.ts +0 -139
- package/src/core/history.ts +0 -237
- package/src/core/image-limits.ts +0 -72
- package/src/core/images.ts +0 -46
- package/src/core/integration-health.ts +0 -34
- package/src/core/json-event.ts +0 -131
- package/src/core/json-pointer.ts +0 -46
- package/src/core/keymap.ts +0 -153
- package/src/core/lsp-framing.ts +0 -34
- package/src/core/mcp-config.ts +0 -68
- package/src/core/model-picker.ts +0 -64
- package/src/core/model-preferences.ts +0 -28
- package/src/core/model-replay.ts +0 -22
- package/src/core/model-scope.ts +0 -53
- package/src/core/note-guidance.ts +0 -34
- package/src/core/oauth-fields.ts +0 -44
- package/src/core/observation-view.ts +0 -152
- package/src/core/outcome.ts +0 -44
- package/src/core/overflow.ts +0 -59
- package/src/core/packages.ts +0 -47
- package/src/core/patch.ts +0 -130
- package/src/core/path-suggestions.ts +0 -59
- package/src/core/permissions.ts +0 -410
- package/src/core/process-receipt.ts +0 -105
- package/src/core/process-state.ts +0 -37
- package/src/core/program-status.ts +0 -40
- package/src/core/progress.ts +0 -150
- package/src/core/project.ts +0 -431
- package/src/core/prompt-policy.ts +0 -46
- package/src/core/prompts.ts +0 -111
- package/src/core/question.ts +0 -94
- package/src/core/queue.ts +0 -78
- package/src/core/read-bounds.ts +0 -24
- package/src/core/read-observation.ts +0 -106
- package/src/core/recovery-sources.ts +0 -74
- package/src/core/regex-dialect.ts +0 -25
- package/src/core/request-budget.ts +0 -23
- package/src/core/request-progress.ts +0 -87
- package/src/core/request-state.ts +0 -273
- package/src/core/resource-selection.ts +0 -36
- package/src/core/response-recovery.ts +0 -59
- package/src/core/resume.ts +0 -59
- package/src/core/review.ts +0 -68
- package/src/core/ripgrep-release.ts +0 -64
- package/src/core/runtime-target.ts +0 -94
- package/src/core/scheduling.ts +0 -8
- package/src/core/search-observation.ts +0 -97
- package/src/core/secrets.ts +0 -188
- package/src/core/session-archive.ts +0 -4
- package/src/core/session-browser.ts +0 -82
- package/src/core/session-entry.ts +0 -61
- package/src/core/session-exit.ts +0 -42
- package/src/core/session-format.ts +0 -169
- package/src/core/session-position.ts +0 -26
- package/src/core/session-tree.ts +0 -147
- package/src/core/settings.ts +0 -192
- package/src/core/skills.ts +0 -165
- package/src/core/sql-query.ts +0 -211
- package/src/core/stall.ts +0 -128
- package/src/core/stop-report.ts +0 -68
- package/src/core/text-normalize.ts +0 -81
- package/src/core/theme.ts +0 -166
- package/src/core/thinking.ts +0 -26
- package/src/core/tokens.ts +0 -151
- package/src/core/tool-contract.ts +0 -153
- package/src/core/tool-discovery.ts +0 -87
- package/src/core/tool-failure.ts +0 -81
- package/src/core/tool-input.ts +0 -44
- package/src/core/transcript.ts +0 -108
- package/src/core/tui-mode.ts +0 -29
- package/src/core/update-check.ts +0 -157
- package/src/core/version.ts +0 -55
- package/src/core/web-content.ts +0 -80
- package/src/core/work-state.ts +0 -154
- package/src/core/workflow-mode.ts +0 -34
- package/src/core/working-set.ts +0 -119
- package/src/core/yaml.ts +0 -282
- package/src/runtime/acp-agent.ts +0 -289
- package/src/runtime/agent-profiles.ts +0 -21
- package/src/runtime/auth.ts +0 -367
- package/src/runtime/blind-check.ts +0 -36
- package/src/runtime/branch-jobs.ts +0 -36
- package/src/runtime/catalog.ts +0 -285
- package/src/runtime/clipboard.ts +0 -125
- package/src/runtime/command-artifacts.ts +0 -64
- package/src/runtime/connection-reuse.ts +0 -40
- package/src/runtime/control-client.ts +0 -149
- package/src/runtime/control-connection.ts +0 -139
- package/src/runtime/counter-check.ts +0 -74
- package/src/runtime/environment-tools.ts +0 -60
- package/src/runtime/evidence-notes.ts +0 -35
- package/src/runtime/exec-sessions.ts +0 -456
- package/src/runtime/execution-control.ts +0 -40
- package/src/runtime/extension-loader.ts +0 -37
- package/src/runtime/extension-ui.ts +0 -47
- package/src/runtime/extensions.ts +0 -370
- package/src/runtime/formatter-tool.ts +0 -38
- package/src/runtime/formatter.ts +0 -50
- package/src/runtime/foundation.ts +0 -116
- package/src/runtime/history-cache.ts +0 -57
- package/src/runtime/history-tools.ts +0 -168
- package/src/runtime/images.ts +0 -80
- package/src/runtime/integrations.ts +0 -216
- package/src/runtime/loop.ts +0 -3108
- package/src/runtime/lsp-client.ts +0 -201
- package/src/runtime/mcp-client.ts +0 -264
- package/src/runtime/mcp-oauth.ts +0 -168
- package/src/runtime/mcp-schema.ts +0 -51
- package/src/runtime/model-config.ts +0 -232
- package/src/runtime/model-data.LICENSE +0 -27
- package/src/runtime/model-data.ts +0 -21
- package/src/runtime/model-discovery.ts +0 -208
- package/src/runtime/model-replay.ts +0 -10
- package/src/runtime/model-scope.ts +0 -20
- package/src/runtime/model-sources.ts +0 -151
- package/src/runtime/model.ts +0 -686
- package/src/runtime/models-fetch.ts +0 -57
- package/src/runtime/oauth-device.ts +0 -86
- package/src/runtime/oauth.ts +0 -853
- package/src/runtime/open-image.ts +0 -42
- package/src/runtime/openai-responses.ts +0 -304
- package/src/runtime/openai.ts +0 -697
- package/src/runtime/output-schema.ts +0 -26
- package/src/runtime/package-manager.ts +0 -125
- package/src/runtime/patch-tool.ts +0 -38
- package/src/runtime/permission-resources.ts +0 -18
- package/src/runtime/pi-provider.ts +0 -423
- package/src/runtime/program-status.ts +0 -38
- package/src/runtime/progress.ts +0 -26
- package/src/runtime/provider-access.ts +0 -21
- package/src/runtime/provider-connect.ts +0 -109
- package/src/runtime/provider-presets.ts +0 -98
- package/src/runtime/provider-protocols.ts +0 -29
- package/src/runtime/provider-request.ts +0 -87
- package/src/runtime/provider-tool-policy.ts +0 -33
- package/src/runtime/provider.ts +0 -580
- package/src/runtime/question-tool.ts +0 -62
- package/src/runtime/retry.ts +0 -447
- package/src/runtime/review-tool.ts +0 -48
- package/src/runtime/rich-text.ts +0 -36
- package/src/runtime/runtime-target.ts +0 -149
- package/src/runtime/secrets.ts +0 -79
- package/src/runtime/self-update.ts +0 -74
- package/src/runtime/session-control.ts +0 -265
- package/src/runtime/session-hub.ts +0 -85
- package/src/runtime/stream-idle.ts +0 -80
- package/src/runtime/tool-broker.ts +0 -137
- package/src/runtime/tool-discovery.ts +0 -58
- package/src/runtime/tool-queue.ts +0 -77
- package/src/runtime/tools.ts +0 -1820
- package/src/runtime/turn-effects.ts +0 -31
- package/src/runtime/update-check.ts +0 -48
- package/src/runtime/usage.ts +0 -253
- package/src/runtime/version.ts +0 -12
- package/src/runtime/web-http.ts +0 -56
- package/src/runtime/web-tools.ts +0 -88
- package/src/runtime/working-notes.ts +0 -60
- package/src/runtime/workspace-lifecycle.ts +0 -141
- package/src/store/acceptance.ts +0 -15
- package/src/store/agent-profile-files.ts +0 -13
- package/src/store/atomic.ts +0 -31
- package/src/store/auth-store.ts +0 -50
- package/src/store/blind-checks.ts +0 -32
- package/src/store/blobs.ts +0 -143
- package/src/store/checkpoint-history.ts +0 -32
- package/src/store/command-artifacts.ts +0 -96
- package/src/store/command.ts +0 -111
- package/src/store/config.ts +0 -69
- package/src/store/context-files.ts +0 -436
- package/src/store/file-checkpoints.ts +0 -108
- package/src/store/file-index.ts +0 -174
- package/src/store/foundation-config.ts +0 -24
- package/src/store/git-checkpoints.ts +0 -67
- package/src/store/git-status.ts +0 -15
- package/src/store/history-search.ts +0 -19
- package/src/store/log.ts +0 -725
- package/src/store/lsp-document.ts +0 -30
- package/src/store/mcp-credentials.ts +0 -85
- package/src/store/models.ts +0 -60
- package/src/store/observations.ts +0 -224
- package/src/store/packages.ts +0 -98
- package/src/store/patch.ts +0 -92
- package/src/store/permission-paths.ts +0 -34
- package/src/store/process-receipts.ts +0 -34
- package/src/store/query-source.ts +0 -49
- package/src/store/recovery.ts +0 -44
- package/src/store/replay.ts +0 -76
- package/src/store/resource-index.ts +0 -170
- package/src/store/resources.ts +0 -285
- package/src/store/restore.ts +0 -110
- package/src/store/review.ts +0 -109
- package/src/store/runtime-target.ts +0 -64
- package/src/store/search.ts +0 -728
- package/src/store/secrets.ts +0 -44
- package/src/store/serial.ts +0 -13
- package/src/store/session-archive.ts +0 -165
- package/src/store/session-browser.ts +0 -68
- package/src/store/session-position.ts +0 -27
- package/src/store/sessions.ts +0 -525
- package/src/store/source-cancellation.ts +0 -6
- package/src/store/tool-binaries.ts +0 -90
- package/src/store/trust.ts +0 -82
- package/src/store/update-cache.ts +0 -36
- package/src/store/user-settings.ts +0 -30
- package/src/store/workspace-clone.ts +0 -42
- package/src/store/workspace-observation.ts +0 -113
- package/src/store/workspace.ts +0 -115
- package/src/store/worktree.ts +0 -278
- package/src/tui/ansi-spans.ts +0 -126
- package/src/tui/diff.ts +0 -160
- package/src/tui/exhaustive.ts +0 -14
- package/src/tui/fuzzy.ts +0 -75
- package/src/tui/style.ts +0 -208
- package/src/tui/ui/atomic-draft.ts +0 -121
- package/src/tui/ui/branch-panel.ts +0 -47
- package/src/tui/ui/canvas.ts +0 -96
- package/src/tui/ui/colors.ts +0 -70
- package/src/tui/ui/composer.ts +0 -199
- package/src/tui/ui/control-panel.ts +0 -25
- package/src/tui/ui/control-sidebar.ts +0 -49
- package/src/tui/ui/dialog.ts +0 -398
- package/src/tui/ui/draft.ts +0 -23
- package/src/tui/ui/duration.ts +0 -16
- package/src/tui/ui/edge.ts +0 -186
- package/src/tui/ui/image-size.ts +0 -44
- package/src/tui/ui/ink.ts +0 -210
- package/src/tui/ui/items.ts +0 -42
- package/src/tui/ui/keys.ts +0 -40
- package/src/tui/ui/layout.ts +0 -74
- package/src/tui/ui/list-model.ts +0 -86
- package/src/tui/ui/list-view.ts +0 -172
- package/src/tui/ui/mascot.ts +0 -67
- package/src/tui/ui/messages.ts +0 -518
- package/src/tui/ui/motion.ts +0 -28
- package/src/tui/ui/paths.ts +0 -10
- package/src/tui/ui/pictures.ts +0 -108
- package/src/tui/ui/prompt-history.ts +0 -60
- package/src/tui/ui/screen.ts +0 -579
- package/src/tui/ui/status.ts +0 -403
- package/src/tui/ui/styled.ts +0 -85
- package/src/tui/ui/tool-card.ts +0 -150
- package/src/tui/width.ts +0 -567
- package/src/vendor/pi-rich/LICENSE.upstream +0 -21
- package/src/vendor/pi-rich/UPSTREAM.json +0 -17
- package/src/vendor/pi-rich/clipboard-command.ts +0 -44
- package/src/vendor/pi-rich/exif-orientation.ts +0 -182
- package/src/vendor/pi-rich/html.ts +0 -51
- package/src/vendor/pi-rich/image-convert.ts +0 -49
- package/src/vendor/pi-rich/image-resize-core.ts +0 -164
- package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
- package/src/vendor/pi-rich/image-resize.ts +0 -123
- package/src/vendor/pi-rich/mime.ts +0 -116
- package/src/vendor/pi-rich/photon.ts +0 -139
- package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
|
@@ -1,488 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What this request's executed commands did outside the workspace
|
|
3
|
-
* (feat-external-effects-summary).
|
|
4
|
-
*
|
|
5
|
-
* The permission design deliberately has no command-danger classifier
|
|
6
|
-
* (permissions.ts): a screen that misses something implies someone is
|
|
7
|
-
* checking. This is the opposite stance -- a statement after the fact, shown
|
|
8
|
-
* to the person when a request ends, never a gate. Its failure mode decides
|
|
9
|
-
* its shape: a missed item is one line fewer; a wrong item teaches the person
|
|
10
|
-
* to ignore the line. So everything here prefers silence to a guess:
|
|
11
|
-
* unparseable commands are skipped whole, and a path containing a variable or
|
|
12
|
-
* a command substitution is never resolved.
|
|
13
|
-
*
|
|
14
|
-
* Only executed commands count: the input is `tool_result` receipts with a
|
|
15
|
-
* `command` effect, which a denied or cancelled call never has. Nothing here
|
|
16
|
-
* is stored; the list is recomputed from the log whenever it is shown (D8).
|
|
17
|
-
*
|
|
18
|
-
* Pure: the home and temporary directories are parameters, never read here.
|
|
19
|
-
*/
|
|
20
|
-
import type { LogEvent } from "./events.ts";
|
|
21
|
-
import { normalizePermissionPath } from "./permissions.ts";
|
|
22
|
-
|
|
23
|
-
export type ExternalCategory = "write" | "process" | "system" | "network" | "push";
|
|
24
|
-
export interface ExternalEffect {
|
|
25
|
-
readonly category: ExternalCategory;
|
|
26
|
-
/** The simple command that did it, truncated for display. */
|
|
27
|
-
readonly command: string;
|
|
28
|
-
/** The receipt that ran it. */
|
|
29
|
-
readonly event: string;
|
|
30
|
-
/** The resolved outside path, for writes. */
|
|
31
|
-
readonly path?: string;
|
|
32
|
-
}
|
|
33
|
-
export interface ExternalOptions {
|
|
34
|
-
/** Expands `~` and `$HOME`. Absent: such paths are dynamic and skipped. */
|
|
35
|
-
readonly home?: string;
|
|
36
|
-
/** Extra temporary roots (the platform's os.tmpdir()); /tmp and /var/tmp are always temporary. */
|
|
37
|
-
readonly temp?: readonly string[];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Display bound for one item; long heredoc commands would otherwise fill the line. */
|
|
41
|
-
const SNIPPET_CHARS = 60;
|
|
42
|
-
/** Assumed bound on nested `sh -c` analysis; deeper nesting is skipped, not guessed. */
|
|
43
|
-
const MAX_NESTING = 3;
|
|
44
|
-
const ALWAYS_TEMPORARY = ["/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp", "/dev"];
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// Lexer: a deliberately small subset of POSIX shell. Anything outside it makes
|
|
48
|
-
// the whole command unparseable, which means "report nothing".
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
|
|
51
|
-
/** A word with its literal value, or null when part of it is only known at run time. */
|
|
52
|
-
type Word = { text: string; value: string | null; quoted: boolean };
|
|
53
|
-
type Token =
|
|
54
|
-
| { kind: "word"; word: Word }
|
|
55
|
-
| { kind: "op"; op: string }
|
|
56
|
-
| { kind: "redirect"; op: string; fd: string };
|
|
57
|
-
|
|
58
|
-
const OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "(", ")", "\n"];
|
|
59
|
-
|
|
60
|
-
/** Tokens, or null when the command does not fit the subset (unclosed quote, unterminated heredoc). */
|
|
61
|
-
function lex(input: string, home: string | undefined): Token[] | null {
|
|
62
|
-
const tokens: Token[] = [];
|
|
63
|
-
const heredocs: { delimiter: string; strip: boolean }[] = [];
|
|
64
|
-
let i = 0;
|
|
65
|
-
const at = (offset = 0) => input[i + offset] ?? "";
|
|
66
|
-
while (i < input.length) {
|
|
67
|
-
const char = at();
|
|
68
|
-
if (char === "\n") {
|
|
69
|
-
tokens.push({ kind: "op", op: "\n" }); i++;
|
|
70
|
-
// Heredoc bodies are data, never commands: skip them line by line.
|
|
71
|
-
for (const doc of heredocs.splice(0)) {
|
|
72
|
-
for (;;) {
|
|
73
|
-
if (i >= input.length) return null;
|
|
74
|
-
const end = input.indexOf("\n", i);
|
|
75
|
-
const line = input.slice(i, end < 0 ? input.length : end);
|
|
76
|
-
i = end < 0 ? input.length : end + 1;
|
|
77
|
-
if ((doc.strip ? line.replace(/^\t+/, "") : line) === doc.delimiter) break;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
if (char === " " || char === "\t") { i++; continue; }
|
|
83
|
-
if (char === "\\" && at(1) === "\n") { i += 2; continue; }
|
|
84
|
-
if (char === "#") { while (i < input.length && at() !== "\n") i++; continue; }
|
|
85
|
-
// Redirections, optionally with a leading descriptor (2>, &>).
|
|
86
|
-
const redirect = /^(\d*|&)(>>|>\||>&|>|<<<|<<-|<<|<>|<&|<)/.exec(input.slice(i));
|
|
87
|
-
if (redirect && (redirect[1] !== "&" || redirect[2]!.startsWith(">"))) {
|
|
88
|
-
const op = redirect[2]!;
|
|
89
|
-
i += redirect[0].length;
|
|
90
|
-
if (op === "<<" || op === "<<-") {
|
|
91
|
-
while (at() === " " || at() === "\t") i++;
|
|
92
|
-
const word = readWord();
|
|
93
|
-
if (word === null || word.text === "") return null;
|
|
94
|
-
heredocs.push({ delimiter: word.text, strip: op === "<<-" });
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
tokens.push({ kind: "redirect", op, fd: redirect[1]! });
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
const operator = OPERATORS.find(candidate => input.startsWith(candidate, i));
|
|
101
|
-
if (operator) { tokens.push({ kind: "op", op: operator }); i += operator.length; continue; }
|
|
102
|
-
const word = readWord();
|
|
103
|
-
if (word === null) return null;
|
|
104
|
-
tokens.push({ kind: "word", word });
|
|
105
|
-
}
|
|
106
|
-
if (heredocs.length) return null;
|
|
107
|
-
return tokens;
|
|
108
|
-
|
|
109
|
-
/** One word; `text` is the unquoted spelling (heredoc delimiters, display), `value` the resolved literal. */
|
|
110
|
-
function readWord(): Word | null {
|
|
111
|
-
let text = "", value: string | null = "", quoted = false;
|
|
112
|
-
const start = i;
|
|
113
|
-
const dynamic = () => { value = null; };
|
|
114
|
-
while (i < input.length) {
|
|
115
|
-
const char = at();
|
|
116
|
-
if (" \t\n;&|()<>".includes(char)) break;
|
|
117
|
-
if (char === "'") {
|
|
118
|
-
const end = input.indexOf("'", i + 1);
|
|
119
|
-
if (end < 0) return null;
|
|
120
|
-
const part = input.slice(i + 1, end);
|
|
121
|
-
text += part; if (value !== null) value += part; quoted = true; i = end + 1; continue;
|
|
122
|
-
}
|
|
123
|
-
if (char === '"') {
|
|
124
|
-
i++; quoted = true;
|
|
125
|
-
for (;;) {
|
|
126
|
-
if (i >= input.length) return null;
|
|
127
|
-
const inner = at();
|
|
128
|
-
if (inner === '"') { i++; break; }
|
|
129
|
-
if (inner === "\\" && '$`"\\\n'.includes(at(1))) { text += at(1); if (value !== null) value += at(1); i += 2; continue; }
|
|
130
|
-
if (inner === "$" || inner === "`") {
|
|
131
|
-
const expanded = expansion(true);
|
|
132
|
-
if (expanded === undefined) return null;
|
|
133
|
-
text += expanded.text; if (expanded.value === null) dynamic(); else if (value !== null) value += expanded.value;
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
text += inner; if (value !== null) value += inner; i++;
|
|
137
|
-
}
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
if (char === "\\") {
|
|
141
|
-
if (i + 1 >= input.length) return null;
|
|
142
|
-
text += at(1); if (value !== null) value += at(1); i += 2; continue;
|
|
143
|
-
}
|
|
144
|
-
if (char === "$" || char === "`") {
|
|
145
|
-
const expanded = expansion(false);
|
|
146
|
-
if (expanded === undefined) return null;
|
|
147
|
-
text += expanded.text; if (expanded.value === null) dynamic(); else if (value !== null) value += expanded.value;
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
if (char === "~" && i === start) {
|
|
151
|
-
// Only a bare leading ~ or ~/ is the home directory; ~user is not guessed.
|
|
152
|
-
const next = at(1);
|
|
153
|
-
if (next === "" || next === "/" || " \t\n;&|()<>".includes(next)) {
|
|
154
|
-
text += "~"; if (home === undefined) dynamic(); else if (value !== null) value += home; i++; continue;
|
|
155
|
-
}
|
|
156
|
-
text += char; dynamic(); i++; continue;
|
|
157
|
-
}
|
|
158
|
-
if ("*?[{".includes(char)) { text += char; dynamic(); i++; continue; }
|
|
159
|
-
text += char; if (value !== null) value += char; i++;
|
|
160
|
-
}
|
|
161
|
-
return { text, value, quoted };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** `$HOME` / `${HOME}` expand; every other expansion makes the word dynamic. undefined: unparseable. */
|
|
165
|
-
function expansion(inDouble: boolean): { text: string; value: string | null } | undefined {
|
|
166
|
-
const start = i;
|
|
167
|
-
if (at() === "`") {
|
|
168
|
-
const end = input.indexOf("`", i + 1);
|
|
169
|
-
if (end < 0) return undefined;
|
|
170
|
-
i = end + 1; return { text: input.slice(start, i), value: null };
|
|
171
|
-
}
|
|
172
|
-
// $(...) / $((...)): balanced parentheses, quotes inside are skipped coarsely.
|
|
173
|
-
if (at(1) === "(") {
|
|
174
|
-
let depth = 0;
|
|
175
|
-
for (; i < input.length; i++) {
|
|
176
|
-
const char = at();
|
|
177
|
-
if (char === "'" && !inDouble) { const end = input.indexOf("'", i + 1); if (end < 0) return undefined; i = end; continue; }
|
|
178
|
-
if (char === "(") depth++;
|
|
179
|
-
if (char === ")" && --depth === 0) { i++; return { text: input.slice(start, i), value: null }; }
|
|
180
|
-
}
|
|
181
|
-
return undefined;
|
|
182
|
-
}
|
|
183
|
-
if (at(1) === "{") {
|
|
184
|
-
const end = input.indexOf("}", i);
|
|
185
|
-
if (end < 0) return undefined;
|
|
186
|
-
const name = input.slice(i + 2, end);
|
|
187
|
-
i = end + 1;
|
|
188
|
-
return { text: input.slice(start, i), value: name === "HOME" && home !== undefined ? home : null };
|
|
189
|
-
}
|
|
190
|
-
const name = /^[A-Za-z_][A-Za-z0-9_]*|^[0-9@*#?$!-]/.exec(input.slice(i + 1))?.[0];
|
|
191
|
-
if (!name) { i++; return { text: "$", value: "$" }; }
|
|
192
|
-
i += 1 + name.length;
|
|
193
|
-
return { text: input.slice(start, i), value: name === "HOME" && home !== undefined ? home : null };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
// Commands
|
|
199
|
-
// ---------------------------------------------------------------------------
|
|
200
|
-
|
|
201
|
-
type Simple = { words: Word[]; redirects: { op: string; fd: string; target: Word | null }[]; text: string };
|
|
202
|
-
type Context = { root: string; options: ExternalOptions; event: string; out: ExternalEffect[]; depth: number };
|
|
203
|
-
|
|
204
|
-
const WRITE_COMMANDS = new Set(["cp", "mv", "ln", "rm", "rmdir", "mkdir", "touch", "chmod", "chown", "chgrp", "tee", "install", "rsync", "truncate"]);
|
|
205
|
-
/** Words that run the rest of the line as a command; their own options are skipped. */
|
|
206
|
-
const PREFIXES = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "nohup", "time", "nice", "xargs"]);
|
|
207
|
-
|
|
208
|
-
function inside(path: string, root: string): boolean {
|
|
209
|
-
return path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function absolute(path: string, cwd: string): string {
|
|
213
|
-
return normalizePermissionPath(path.startsWith("/") ? path : `${cwd}/${path}`);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function snippet(text: string): string {
|
|
217
|
-
const line = text.replace(/\s+/g, " ").trim();
|
|
218
|
-
return line.length > SNIPPET_CHARS ? `${line.slice(0, SNIPPET_CHARS - 1)}…` : line;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** The outside path a literal target resolves to, or null (inside, temporary, or dynamic). */
|
|
222
|
-
function outside(target: Word | null, cwd: string | null, ctx: Context): string | null {
|
|
223
|
-
if (!target || target.value === null || target.value === "" || cwd === null) return null;
|
|
224
|
-
// `-` is stdout/stdin for tee and friends.
|
|
225
|
-
if (target.value === "-") return null;
|
|
226
|
-
const path = absolute(target.value, cwd);
|
|
227
|
-
const root = normalizePermissionPath(ctx.root);
|
|
228
|
-
if (inside(path, root)) return null;
|
|
229
|
-
for (const temp of [...ALWAYS_TEMPORARY, ...ctx.options.temp ?? []]) if (inside(path, normalizePermissionPath(temp))) return null;
|
|
230
|
-
return path;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** Splits tokens into simple commands; `cd` state is threaded across `&&`, `;` and newlines, restored by `)`. */
|
|
234
|
-
function analyse(tokens: Token[], cwd: string, ctx: Context): void {
|
|
235
|
-
const stack: (string | null)[] = [];
|
|
236
|
-
let current: string | null = cwd;
|
|
237
|
-
let simple: Simple = { words: [], redirects: [], text: "" };
|
|
238
|
-
let pendingRedirect: { op: string; fd: string } | null = null;
|
|
239
|
-
const flush = () => {
|
|
240
|
-
if (pendingRedirect) { simple.redirects.push({ ...pendingRedirect, target: null }); pendingRedirect = null; }
|
|
241
|
-
if (simple.words.length || simple.redirects.length) current = command(simple, current, ctx);
|
|
242
|
-
simple = { words: [], redirects: [], text: "" };
|
|
243
|
-
};
|
|
244
|
-
for (const token of tokens) {
|
|
245
|
-
if (token.kind === "op") {
|
|
246
|
-
flush();
|
|
247
|
-
if (token.op === "(") stack.push(current);
|
|
248
|
-
if (token.op === ")") current = stack.length ? stack.pop()! : current;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (token.kind === "redirect") {
|
|
252
|
-
if (pendingRedirect) simple.redirects.push({ ...pendingRedirect, target: null });
|
|
253
|
-
pendingRedirect = { op: token.op, fd: token.fd };
|
|
254
|
-
simple.text += `${simple.text ? " " : ""}${token.fd}${token.op}`;
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
simple.text += `${simple.text && !pendingRedirect ? " " : ""}${token.word.text}`;
|
|
258
|
-
if (pendingRedirect) { simple.redirects.push({ ...pendingRedirect, target: token.word }); pendingRedirect = null; continue; }
|
|
259
|
-
simple.words.push(token.word);
|
|
260
|
-
}
|
|
261
|
-
flush();
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** One simple command; returns the working directory after it (null: unknown, so later paths are not guessed). */
|
|
265
|
-
function command(simple: Simple, cwd: string | null, ctx: Context): string | null {
|
|
266
|
-
const add = (category: ExternalCategory, path?: string) => {
|
|
267
|
-
if (ctx.out.some(item => item.event === ctx.event && item.category === category && item.path === path && item.command === snippet(simple.text))) return;
|
|
268
|
-
ctx.out.push({ category, command: snippet(simple.text), event: ctx.event, ...(path === undefined ? {} : { path }) });
|
|
269
|
-
};
|
|
270
|
-
for (const redirect of simple.redirects) {
|
|
271
|
-
if (!redirect.op.startsWith(">") && redirect.op !== "<>") continue;
|
|
272
|
-
// >&2 duplicates a descriptor; it names no file.
|
|
273
|
-
if (redirect.op === ">&" && redirect.target?.value !== null && /^\d+$|^-$/.test(redirect.target?.value ?? "")) continue;
|
|
274
|
-
const path = outside(redirect.target, cwd, ctx);
|
|
275
|
-
if (path) add("write", path);
|
|
276
|
-
}
|
|
277
|
-
let words = simple.words;
|
|
278
|
-
// Leading assignments (FOO=bar cmd) and wrappers (sudo, env -i) are not the command.
|
|
279
|
-
for (;;) {
|
|
280
|
-
while (words.length && words[0]!.value !== null && !words[0]!.quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]!.value!)) words = words.slice(1);
|
|
281
|
-
const head = words[0]?.value;
|
|
282
|
-
if (head === undefined || head === null || !PREFIXES.has(baseName(head))) break;
|
|
283
|
-
words = words.slice(1);
|
|
284
|
-
while (words.length && (words[0]!.value ?? "").startsWith("-")) words = words.slice(1);
|
|
285
|
-
}
|
|
286
|
-
if (!words.length) return cwd;
|
|
287
|
-
const name = words[0]!.value;
|
|
288
|
-
if (name === null) return cwd;
|
|
289
|
-
const base = baseName(name);
|
|
290
|
-
const args = words.slice(1);
|
|
291
|
-
const values = args.map(word => word.value);
|
|
292
|
-
|
|
293
|
-
switch (base) {
|
|
294
|
-
case "cd": case "pushd": {
|
|
295
|
-
const target = args.find(word => !(word.value ?? "").startsWith("-") || word.value === "-");
|
|
296
|
-
if (!target) return ctx.options.home ?? null;
|
|
297
|
-
if (target.value === null || target.value === "-" || cwd === null) return null;
|
|
298
|
-
return absolute(target.value, cwd);
|
|
299
|
-
}
|
|
300
|
-
case "popd": return null;
|
|
301
|
-
case "sh": case "bash": case "zsh": case "dash": {
|
|
302
|
-
const flag = args.findIndex(word => /^-[a-z]*c[a-z]*$/.test(word.value ?? ""));
|
|
303
|
-
const script = flag >= 0 ? args[flag + 1] : undefined;
|
|
304
|
-
if (script?.value && ctx.depth < MAX_NESTING) {
|
|
305
|
-
const tokens = lex(script.value, ctx.options.home);
|
|
306
|
-
if (tokens && cwd !== null) analyse(tokens, cwd, { ...ctx, depth: ctx.depth + 1 });
|
|
307
|
-
}
|
|
308
|
-
return cwd;
|
|
309
|
-
}
|
|
310
|
-
case "kill":
|
|
311
|
-
// kill -0 probes and kill -l lists; neither changes a process.
|
|
312
|
-
if (values.some(value => value === "-0" || value === "-l" || value === "-L" || value === "-s0")) return cwd;
|
|
313
|
-
if (args.length) add("process");
|
|
314
|
-
return cwd;
|
|
315
|
-
case "killall": case "pkill":
|
|
316
|
-
if (args.length) add("process");
|
|
317
|
-
return cwd;
|
|
318
|
-
case "launchctl": {
|
|
319
|
-
const sub = values[0];
|
|
320
|
-
if (sub && ["load", "unload", "bootstrap", "bootout", "kickstart", "kill", "start", "stop", "enable", "disable", "remove", "submit", "setenv", "unsetenv"].includes(sub)) add("system");
|
|
321
|
-
return cwd;
|
|
322
|
-
}
|
|
323
|
-
case "systemctl": {
|
|
324
|
-
const sub = values.find(value => value !== null && !value.startsWith("-"));
|
|
325
|
-
if (sub && ["start", "stop", "restart", "reload", "enable", "disable", "mask", "unmask", "kill", "daemon-reload", "set-property", "edit", "isolate", "try-restart", "reload-or-restart"].includes(sub)) add("system");
|
|
326
|
-
return cwd;
|
|
327
|
-
}
|
|
328
|
-
case "defaults": {
|
|
329
|
-
const sub = values.find(value => value !== null && !value.startsWith("-"));
|
|
330
|
-
if (sub && ["write", "delete", "import", "rename"].includes(sub)) add("system");
|
|
331
|
-
return cwd;
|
|
332
|
-
}
|
|
333
|
-
case "brew": {
|
|
334
|
-
const sub = values.find(value => value !== null && !value.startsWith("-"));
|
|
335
|
-
if (sub && ["install", "upgrade", "uninstall", "reinstall", "remove", "rm", "link", "unlink", "tap", "untap"].includes(sub)) add("system");
|
|
336
|
-
return cwd;
|
|
337
|
-
}
|
|
338
|
-
case "npm": case "pnpm": case "yarn": case "bun": {
|
|
339
|
-
const global = values.some(value => value === "-g" || value === "--global" || value === "--location=global");
|
|
340
|
-
const sub = values.find(value => value !== null && !value.startsWith("-"));
|
|
341
|
-
if (global && sub && ["install", "i", "add", "uninstall", "remove", "rm", "un", "update", "upgrade", "link"].includes(sub)) add("system");
|
|
342
|
-
if (base === "yarn" && sub === "global") add("system");
|
|
343
|
-
return cwd;
|
|
344
|
-
}
|
|
345
|
-
case "pip": case "pip3": {
|
|
346
|
-
const sub = values.find(value => value !== null && !value.startsWith("-"));
|
|
347
|
-
if (sub === "install" || sub === "uninstall") add("system");
|
|
348
|
-
return cwd;
|
|
349
|
-
}
|
|
350
|
-
case "python": case "python3": {
|
|
351
|
-
const module = values.indexOf("-m");
|
|
352
|
-
if (module >= 0 && /^pip3?$/.test(values[module + 1] ?? "") && ["install", "uninstall"].includes(values[module + 2] ?? "")) add("system");
|
|
353
|
-
return cwd;
|
|
354
|
-
}
|
|
355
|
-
case "curl":
|
|
356
|
-
if (curlMutates(values)) add("network");
|
|
357
|
-
return cwd;
|
|
358
|
-
case "wget":
|
|
359
|
-
if (values.some(value => value !== null && /^--(post-data|post-file|body-data|body-file|method)(=|$)/.test(value))) add("network");
|
|
360
|
-
return cwd;
|
|
361
|
-
case "git": {
|
|
362
|
-
// Global options before the subcommand: -C dir, -c k=v, --git-dir=...
|
|
363
|
-
let index = 0;
|
|
364
|
-
while (index < values.length && (values[index] ?? "").startsWith("-")) index += ["-C", "-c"].includes(values[index] ?? "") ? 2 : 1;
|
|
365
|
-
if (values[index] === "push" && !values.slice(index + 1).some(value => value === "--dry-run" || value === "-n")) add("push");
|
|
366
|
-
return cwd;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
// A dynamic operand could be any path; writeTargets judges only literal ones.
|
|
370
|
-
if (WRITE_COMMANDS.has(base)) for (const path of writeTargets(base, args, cwd, ctx)) add("write", path);
|
|
371
|
-
return cwd;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function baseName(path: string): string {
|
|
375
|
-
return path.slice(path.lastIndexOf("/") + 1);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Paths the command writes, among its literal operands, resolved and outside. */
|
|
379
|
-
function writeTargets(base: string, args: Word[], cwd: string | null, ctx: Context): string[] {
|
|
380
|
-
const operands: Word[] = [];
|
|
381
|
-
let targetDirectory: Word | null = null;
|
|
382
|
-
let options = true;
|
|
383
|
-
for (let index = 0; index < args.length; index++) {
|
|
384
|
-
const word = args[index]!;
|
|
385
|
-
const value = word.value;
|
|
386
|
-
if (options && value === "--") { options = false; continue; }
|
|
387
|
-
if (options && value !== null && value.startsWith("-") && value !== "-") {
|
|
388
|
-
if ((base === "cp" || base === "mv" || base === "ln" || base === "install") && (value === "-t" || value === "--target-directory")) { targetDirectory = args[++index] ?? null; continue; }
|
|
389
|
-
if (value.startsWith("--target-directory=")) { targetDirectory = { ...word, value: value.slice("--target-directory=".length) }; continue; }
|
|
390
|
-
// Options that take a separate value; the value is not an operand.
|
|
391
|
-
if ((base === "chmod" || base === "chown" || base === "chgrp") && value === "--reference") { index++; continue; }
|
|
392
|
-
if (base === "install" && ["-m", "-o", "-g"].includes(value)) { index++; continue; }
|
|
393
|
-
if (base === "truncate" && (value === "-s" || value === "-r")) { index++; continue; }
|
|
394
|
-
if (base === "mkdir" && value === "-m") { index++; continue; }
|
|
395
|
-
continue;
|
|
396
|
-
}
|
|
397
|
-
operands.push(word);
|
|
398
|
-
}
|
|
399
|
-
let targets: Word[];
|
|
400
|
-
switch (base) {
|
|
401
|
-
case "cp": case "mv": case "ln": case "install": case "rsync":
|
|
402
|
-
// The destination is the last operand (or -t); sources are read, not written.
|
|
403
|
-
targets = targetDirectory ? [targetDirectory] : operands.length >= 2 ? [operands.at(-1)!] : [];
|
|
404
|
-
// mv also removes its sources.
|
|
405
|
-
if (base === "mv") targets = [...targets, ...operands.slice(0, targetDirectory ? operands.length : -1)];
|
|
406
|
-
break;
|
|
407
|
-
case "chmod": case "chown": case "chgrp":
|
|
408
|
-
// The first operand is the mode or owner.
|
|
409
|
-
targets = operands.slice(1);
|
|
410
|
-
break;
|
|
411
|
-
default:
|
|
412
|
-
targets = operands;
|
|
413
|
-
}
|
|
414
|
-
// rsync destinations may be remote (host:path); those are network, not local writes.
|
|
415
|
-
if (base === "rsync") targets = targets.filter(word => word.value !== null && !/^[^/]*:/.test(word.value));
|
|
416
|
-
return targets.map(word => outside(word, cwd, ctx)).filter((path): path is string => path !== null);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function curlMutates(values: readonly (string | null)[]): boolean {
|
|
420
|
-
let get = false, data = false, method: string | null = null;
|
|
421
|
-
for (let index = 0; index < values.length; index++) {
|
|
422
|
-
const value = values[index];
|
|
423
|
-
if (value === null || value === undefined) continue;
|
|
424
|
-
if (value === "-G" || value === "--get") get = true;
|
|
425
|
-
else if (value === "-X" || value === "--request") method = (values[++index] ?? "").toUpperCase();
|
|
426
|
-
else if (value.startsWith("--request=")) method = value.slice("--request=".length).toUpperCase();
|
|
427
|
-
else if (/^-X./.test(value)) method = value.slice(2).toUpperCase();
|
|
428
|
-
else if (/^--(data|data-raw|data-binary|data-urlencode|data-ascii|json)(=|$)/.test(value) || /^-d/.test(value)) data = true;
|
|
429
|
-
else if (/^--(form|form-string|upload-file)(=|$)/.test(value) || /^-[FT]/.test(value)) return true;
|
|
430
|
-
else if (/^-[a-zA-Z]+$/.test(value) && !value.startsWith("--")) {
|
|
431
|
-
// Bundled short flags: -sSd ... or -sX POST.
|
|
432
|
-
const flags = value.slice(1);
|
|
433
|
-
if (flags.includes("G")) get = true;
|
|
434
|
-
if (/[FT]/.test(flags)) return true;
|
|
435
|
-
if (flags.endsWith("X")) method = (values[++index] ?? "").toUpperCase();
|
|
436
|
-
else if (flags.endsWith("d")) data = true;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
if (method !== null) return method !== "GET" && method !== "HEAD";
|
|
440
|
-
return data && !get;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/** One command string's effects; exported for the property tests and for single-receipt views. */
|
|
444
|
-
export function commandExternalEffects(cmd: string, event: string, workspaceRoot: string, options: ExternalOptions = {}, cwd = workspaceRoot): ExternalEffect[] {
|
|
445
|
-
const tokens = lex(cmd, options.home);
|
|
446
|
-
if (!tokens) return [];
|
|
447
|
-
const out: ExternalEffect[] = [];
|
|
448
|
-
analyse(tokens, normalizePermissionPath(cwd), { root: workspaceRoot, options, event, out, depth: 0 });
|
|
449
|
-
return out;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* The current request's outside effects, in log order. `events` is the
|
|
454
|
-
* request's own slice (currentRequestEvents); a workdir on the call is
|
|
455
|
-
* honoured when it is a literal relative or absolute path.
|
|
456
|
-
*/
|
|
457
|
-
export function externalEffects(events: readonly LogEvent[], workspaceRoot: string, options: ExternalOptions = {}): ExternalEffect[] {
|
|
458
|
-
const workdirs = new Map<string, string>();
|
|
459
|
-
for (const event of events) {
|
|
460
|
-
if (event.t !== "assistant") continue;
|
|
461
|
-
for (const block of event.blocks) {
|
|
462
|
-
if (block.type !== "tool_use" || block.input === null || typeof block.input !== "object") continue;
|
|
463
|
-
const workdir = (block.input as Record<string, unknown>)["workdir"];
|
|
464
|
-
if (typeof workdir === "string" && workdir !== "") workdirs.set(block.call_id, workdir);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
const root = normalizePermissionPath(workspaceRoot);
|
|
468
|
-
const out: ExternalEffect[] = [];
|
|
469
|
-
for (const event of events) {
|
|
470
|
-
if (event.t !== "tool_result" || event.effect?.kind !== "command") continue;
|
|
471
|
-
const workdir = workdirs.get(event.call_id);
|
|
472
|
-
out.push(...commandExternalEffects(event.effect.cmd, event.id, root, options, workdir ? absolute(workdir, root) : root));
|
|
473
|
-
}
|
|
474
|
-
return out;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/** Display bound: the line names the first few and counts the rest. */
|
|
478
|
-
export const EXTERNAL_SHOWN = 5;
|
|
479
|
-
|
|
480
|
-
/** One line for the end of a request, or null when nothing reached outside. */
|
|
481
|
-
export function formatExternalEffects(effects: readonly ExternalEffect[], options: { home?: string } = {}): string | null {
|
|
482
|
-
if (!effects.length) return null;
|
|
483
|
-
const tilde = (path: string) => options.home && inside(path, options.home) ? `~${path.slice(options.home.length)}` : path;
|
|
484
|
-
const label = (effect: ExternalEffect) => effect.category === "write" && effect.path ? `write ${tilde(effect.path)}` : effect.command;
|
|
485
|
-
const shown = effects.slice(0, EXTERNAL_SHOWN).map(label);
|
|
486
|
-
const rest = effects.length - shown.length;
|
|
487
|
-
return `Outside the workspace: ${shown.join(" · ")}${rest > 0 ? ` · and ${rest} more` : ""}`;
|
|
488
|
-
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/** R5 / D8: replayable failure premises; advice never authorizes or automatically retries a tool. */
|
|
2
|
-
import type { GuidanceEvent, LogEvent, ToolResultEvent } from "./events.ts";
|
|
3
|
-
import type { ToolDiagnostic } from "./tool-contract.ts";
|
|
4
|
-
import { canonicalValue } from "./continuation.ts";
|
|
5
|
-
import { repairToolInput } from "./tool-input.ts";
|
|
6
|
-
import { failureDiagnostic, scopeFailureGuidance } from "./tool-failure.ts";
|
|
7
|
-
|
|
8
|
-
/** Reuses the existing three-attempt diagnostic hypothesis, not a measured optimum or a stop threshold. */
|
|
9
|
-
export const RECOVERY_FAILURES = 3;
|
|
10
|
-
type Call = { name: string; input: unknown };
|
|
11
|
-
export interface FailureIdentity {
|
|
12
|
-
family: string;
|
|
13
|
-
resource: string;
|
|
14
|
-
version: string;
|
|
15
|
-
code: ToolDiagnostic["code"];
|
|
16
|
-
execution: NonNullable<ToolDiagnostic["execution"]>;
|
|
17
|
-
effects: NonNullable<ToolDiagnostic["effects"]>;
|
|
18
|
-
premise: string;
|
|
19
|
-
}
|
|
20
|
-
interface FailureRun {
|
|
21
|
-
identity: FailureIdentity;
|
|
22
|
-
key: string;
|
|
23
|
-
count: number;
|
|
24
|
-
sources: readonly string[];
|
|
25
|
-
notified: boolean;
|
|
26
|
-
}
|
|
27
|
-
export interface RecoveryState {
|
|
28
|
-
calls: ReadonlyMap<string, Call>;
|
|
29
|
-
versions: ReadonlyMap<string, string>;
|
|
30
|
-
failures: ReadonlyMap<string, FailureRun>;
|
|
31
|
-
change: number;
|
|
32
|
-
}
|
|
33
|
-
export function emptyRecoveryState(): RecoveryState {
|
|
34
|
-
return { calls: new Map(), versions: new Map(), failures: new Map(), change: 0 };
|
|
35
|
-
}
|
|
36
|
-
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
37
|
-
const digest = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
38
|
-
const fileTools = ["read", "write", "edit", "grep", "find", "ls"];
|
|
39
|
-
const family = (name: string): string => name === "bash" || name === "exec_command" ? "command"
|
|
40
|
-
: name === "edit" || name === "write" ? "file_change" : name;
|
|
41
|
-
|
|
42
|
-
function target(call: Call, identify: (path: string) => string): { family: string; resource: string; input: Record<string, unknown> } {
|
|
43
|
-
const repaired = repairToolInput(call.name, call.input), input = record(repaired) ? repaired : { value: repaired };
|
|
44
|
-
const kind = family(call.name);
|
|
45
|
-
return { family: kind, input, resource: fileTools.includes(call.name) && typeof input["path"] === "string" ? identify(input["path"])
|
|
46
|
-
: kind === "command" ? identify(typeof input["workdir"] === "string" ? input["workdir"] : ".")
|
|
47
|
-
: call.name === "write_stdin" ? `session:${String(input["session_id"])}` : call.name };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function groupKey(call: Call, result: ToolResultEvent, identify: (path: string) => string): string {
|
|
51
|
-
const context = target(call, identify);
|
|
52
|
-
return canonicalValue([context.family, context.resource, context.family === "command"
|
|
53
|
-
? result.effect?.kind === "command" ? result.effect.cmd.trim() : String(context.input["command"] ?? context.input["cmd"] ?? "").trim() : null]);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Only recorded versions and structured failures are facts; stderr words never identify an error. */
|
|
57
|
-
export function failureIdentity(call: Call, result: ToolResultEvent, state: RecoveryState,
|
|
58
|
-
identify: (path: string) => string): FailureIdentity | null {
|
|
59
|
-
const diagnostic = result.diagnostic;
|
|
60
|
-
if (result.ok || !diagnostic || diagnostic.execution === undefined || diagnostic.effects === undefined) return null;
|
|
61
|
-
const context = target(call, identify), data = record(result.data) ? result.data : {};
|
|
62
|
-
const current = data["sha256"];
|
|
63
|
-
const effect = result.effect;
|
|
64
|
-
const known = state.versions.get(context.resource);
|
|
65
|
-
const version = diagnostic.code === "permission_denied" ? "unknown" : current === null ? "absent" : digest(current) ? current
|
|
66
|
-
: effect?.kind === "command" && effect.workspace_observed !== false && digest(effect.workspace_sha256) ? effect.workspace_sha256
|
|
67
|
-
: digest(known) ? known : "unknown";
|
|
68
|
-
const input = context.input;
|
|
69
|
-
let premise: unknown;
|
|
70
|
-
if (diagnostic.code === "version_mismatch") {
|
|
71
|
-
const precondition = record(data["precondition"]) ? data["precondition"] : {};
|
|
72
|
-
premise = { expected: precondition["expected"] === null ? "absent"
|
|
73
|
-
: precondition["expected"] ?? input["expected_sha256"] ?? input["read_handle"] ?? "unknown" };
|
|
74
|
-
} else if (diagnostic.code === "permission_denied") {
|
|
75
|
-
// Replacement wording and requested shell timeouts cannot change a denied capability.
|
|
76
|
-
premise = { operation: context.family === "command" ? effect?.kind === "command" ? effect.cmd.trim()
|
|
77
|
-
: String(input["command"] ?? input["cmd"] ?? "").trim() : context.family };
|
|
78
|
-
} else if (context.family === "file_change" && ["not_found", "ambiguous_edit"].includes(diagnostic.code)) {
|
|
79
|
-
const edits = Array.isArray(input["edits"]) ? input["edits"] : [input];
|
|
80
|
-
premise = edits.map(edit => record(edit) ? { old_string: edit["old_string"], replace_all: edit["replace_all"] === true } : edit);
|
|
81
|
-
} else if (context.family === "command") {
|
|
82
|
-
// Shared launch contract equates bash/exec_command; arbitrary shell strings are not parsed as equivalent.
|
|
83
|
-
premise = { command: effect?.kind === "command" ? effect.cmd.trim() : String(input["command"] ?? input["cmd"] ?? "").trim(),
|
|
84
|
-
exit: effect?.kind === "command" ? effect.exit : null,
|
|
85
|
-
timeout: input["timeout"] ?? null, tty: input["tty"] === true, change: state.change };
|
|
86
|
-
} else {
|
|
87
|
-
const normalized = { ...input };
|
|
88
|
-
if (fileTools.includes(call.name) && typeof normalized["path"] === "string") normalized["path"] = context.resource;
|
|
89
|
-
premise = normalized;
|
|
90
|
-
}
|
|
91
|
-
return { family: context.family, resource: context.resource, version, code: diagnostic.code,
|
|
92
|
-
execution: diagnostic.execution, effects: diagnostic.effects, premise: canonicalValue(premise) };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function changedPremises(state: RecoveryState, changed: boolean, resource?: string, version?: string): RecoveryState {
|
|
96
|
-
return { ...state, change: state.change + Number(changed), failures: new Map([...state.failures].filter(([, run]) =>
|
|
97
|
-
run.identity.code === "permission_denied" || !(changed && run.identity.family === "command" ||
|
|
98
|
-
resource !== undefined && version !== undefined && run.identity.resource === resource && run.identity.version !== version))) };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Incremental disposable projection; folding the original ancestry yields exactly the same state. */
|
|
102
|
-
export function advanceRecovery(state: RecoveryState, events: readonly LogEvent[], identify: (path: string) => string): RecoveryState {
|
|
103
|
-
let current = state;
|
|
104
|
-
for (const event of events) {
|
|
105
|
-
if (event.t === "user") { current = { ...current, failures: new Map() }; continue; }
|
|
106
|
-
if (event.t === "assistant") {
|
|
107
|
-
const calls = new Map(current.calls);
|
|
108
|
-
for (const block of event.blocks) if (block.type === "tool_use") calls.set(block.call_id, { name: block.name, input: block.input });
|
|
109
|
-
current = { ...current, calls }; continue;
|
|
110
|
-
}
|
|
111
|
-
if (event.t === "tool_invocation") {
|
|
112
|
-
if (event.input !== undefined) current = { ...current, calls: new Map(current.calls).set(event.call_id, { name: event.name, input: event.input }) };
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
if (event.t === "guidance" && event.reason === "churn") {
|
|
116
|
-
current = { ...current, failures: new Map([...current.failures].map(([group, run]) =>
|
|
117
|
-
[group, event.sources.includes(run.sources[0]!) ? { ...run, notified: true } : run])) }; continue;
|
|
118
|
-
}
|
|
119
|
-
if (event.t === "workspace_observation" && event.changed) {
|
|
120
|
-
current = changedPremises(current, true); continue;
|
|
121
|
-
}
|
|
122
|
-
if (event.t === "checkpoint" && event.phase === "applied") {
|
|
123
|
-
for (const [path, change] of Object.entries(event.changes))
|
|
124
|
-
current = changedPremises(current, change.before?.sha256 !== change.after?.sha256 || change.before?.mode !== change.after?.mode,
|
|
125
|
-
identify(path), change.after === null ? "absent" : change.after.sha256);
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (event.t !== "tool_result") continue;
|
|
129
|
-
const call = current.calls.get(event.call_id), calls = new Map(current.calls); calls.delete(event.call_id);
|
|
130
|
-
current = { ...current, calls };
|
|
131
|
-
const effect = event.effect;
|
|
132
|
-
if (effect?.kind === "edit" || effect?.kind === "read") {
|
|
133
|
-
const resource = identify(effect.path), previous = current.versions.get(resource);
|
|
134
|
-
const version = effect.kind === "edit" ? effect.sha256_after : effect.sha256;
|
|
135
|
-
const changed = effect.kind === "edit" ? effect.sha256_before !== effect.sha256_after : digest(previous) && previous !== version;
|
|
136
|
-
current = changedPremises({ ...current, versions: new Map(current.versions).set(resource, version) }, changed,
|
|
137
|
-
resource, digest(version) ? version : undefined);
|
|
138
|
-
}
|
|
139
|
-
if (!call) continue;
|
|
140
|
-
const group = groupKey(call, event, identify);
|
|
141
|
-
if (event.ok) {
|
|
142
|
-
const failures = new Map(current.failures); failures.delete(group);
|
|
143
|
-
current = { ...current, failures }; continue;
|
|
144
|
-
}
|
|
145
|
-
const identity = failureIdentity(call, event, current, identify);
|
|
146
|
-
if (!identity) continue;
|
|
147
|
-
const key = canonicalValue(identity), previous = current.failures.get(group);
|
|
148
|
-
const run: FailureRun = previous?.key === key ? { ...previous, count: previous.count + 1,
|
|
149
|
-
sources: [...previous.sources.slice(0, 2), event.id] }
|
|
150
|
-
: { identity, key, count: 1, sources: [event.id], notified: false };
|
|
151
|
-
current = { ...current, failures: new Map(current.failures).set(group, run) };
|
|
152
|
-
}
|
|
153
|
-
return current;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function recoveryReview(state: RecoveryState, tools?: readonly string[]): Pick<GuidanceEvent, "reason" | "text" | "sources"> | null {
|
|
157
|
-
for (const run of state.failures.values()) {
|
|
158
|
-
if (run.count < RECOVERY_FAILURES || run.notified) continue;
|
|
159
|
-
const identity = run.identity;
|
|
160
|
-
// Recompute trusted instructions; never promote arbitrary tool error text to harness instructions.
|
|
161
|
-
const diagnostic = failureDiagnostic(identity.code, "", { execution: identity.execution, effects: identity.effects });
|
|
162
|
-
const policy = tools === undefined ? diagnostic : scopeFailureGuidance(diagnostic, tools);
|
|
163
|
-
return { reason: "churn", sources: run.sources,
|
|
164
|
-
text: `Harness: ${run.count} attempts share an unchanged failure premise in the recorded facts. ` +
|
|
165
|
-
`Tool family ${JSON.stringify(identity.family)}; resource label (data) ${JSON.stringify(identity.resource.slice(0, 160))}; ` +
|
|
166
|
-
`code ${identity.code}; version ${identity.version}; execution ${identity.execution}; effects ${identity.effects}. ` +
|
|
167
|
-
`${policy.next} ` + (policy.retry === "after_inspection" ? "Inspect actual state for partial effects before any replay. " : "") +
|
|
168
|
-
"Identify which relevant premise changed before another attempt; changing replacement wording is not a new premise. " +
|
|
169
|
-
(identity.version === "unknown" ? "The resource version is unknown; this notice does not prove external state is unchanged. " : "") +
|
|
170
|
-
"This is recovery advice, not a task-failure verdict or authorization to replay. Original result references: " + run.sources.join(", ") + "." };
|
|
171
|
-
}
|
|
172
|
-
return null;
|
|
173
|
-
}
|