pi-roundtable 0.22.4 → 0.23.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 +61 -1
- package/README.md +36 -10
- package/README.zh-TW.md +51 -13
- package/docs/migrating-0.8.md +2 -2
- package/docs/plugins.md +350 -28
- package/examples/discord-adapter.ts +9 -1
- package/examples/seeds.test.ts +2 -2
- package/examples/seeds.ts +1 -2
- package/package.json +7 -3
- package/src/characters/catalog.ts +1055 -0
- package/src/characters/characters.css +68 -0
- package/src/characters/colors.ts +108 -0
- package/src/characters/compose.ts +281 -0
- package/src/characters/css.ts +63 -0
- package/src/characters/index.ts +33 -0
- package/src/characters/parse.ts +196 -0
- package/src/characters/parts-face.ts +354 -0
- package/src/characters/parts-hair.ts +56 -0
- package/src/characters/parts-hats.ts +57 -0
- package/src/characters/parts-outfits.ts +81 -0
- package/src/characters/parts-props.ts +137 -0
- package/src/characters/spec.ts +224 -0
- package/src/characters/suggest.ts +101 -0
- package/src/cli/checks/characters.ts +23 -0
- package/src/cli/checks/images.ts +5 -3
- package/src/cli/doctor.ts +2 -0
- package/src/core/agents/agent-claim.ts +30 -11
- package/src/core/agents/agent-dashboard.ts +1 -1
- package/src/core/agents/agent-messages.ts +43 -15
- package/src/core/agents/agent-prompt.ts +2 -4
- package/src/core/agents/agent-rules.ts +42 -3
- package/src/core/agents/agent-schema.ts +37 -15
- package/src/core/agents/agent-store.ts +57 -13
- package/src/core/agents/agent-team-fixture.ts +90 -14
- package/src/core/agents/agent-team.ts +83 -50
- package/src/core/agents/agent-tools.ts +94 -41
- package/src/core/agents/avatar-studio.ts +40 -4
- package/src/core/agents/character-png.ts +40 -0
- package/src/core/agents/group-messages.ts +7 -4
- package/src/core/agents/group-turns.ts +27 -14
- package/src/core/agents/team-editing.ts +99 -58
- package/src/core/agents/team-keys.ts +177 -11
- package/src/core/agents/team-lifecycle.ts +151 -84
- package/src/core/agents/team-options.ts +13 -6
- package/src/core/agents/team-status.ts +31 -13
- package/src/core/agents/team-text.ts +13 -3
- package/src/core/agents/team-turn-types.ts +25 -7
- package/src/core/agents/team-turns.ts +143 -28
- package/src/core/attachments/attachment-fetcher.ts +162 -64
- package/src/core/attachments/prompt-block.ts +7 -1
- package/src/core/attachments/tool-attachment.ts +20 -1
- package/src/core/attachments/turn-attachments.ts +18 -2
- package/src/core/builtin/agent-server.ts +103 -40
- package/src/core/builtin/discord.ts +4 -17
- package/src/core/builtin/modules.ts +6 -5
- package/src/core/builtin/threads.ts +37 -0
- package/src/core/config/agent-team.ts +43 -0
- package/src/core/config/config.ts +50 -59
- package/src/core/config/limits.ts +93 -0
- package/src/core/config/ops.ts +34 -0
- package/src/core/config/schema.ts +26 -0
- package/src/core/contract/channels.ts +20 -0
- package/src/core/contract/surface.ts +77 -7
- package/src/core/define-roundtable.ts +70 -42
- package/src/core/discord/agent-commands.ts +108 -86
- package/src/core/discord/agent-panel.ts +11 -2
- package/src/core/discord/connection.ts +1 -1
- package/src/core/discord/discord-surface.ts +10 -1
- package/src/core/discord/dispatch-thread-host.ts +1 -1
- package/src/core/domain/conversation.ts +24 -0
- package/src/core/holds.ts +221 -15
- package/src/core/host-registration.ts +2 -1
- package/src/core/http/listeners.ts +63 -3
- package/src/core/http/websocket.ts +2 -1
- package/src/core/i18n/agent-panel.ts +14 -21
- package/src/core/interactions/prompts.ts +12 -0
- package/src/core/modes/autonomy.ts +23 -0
- package/src/core/modes/conversation-modes.ts +213 -0
- package/src/core/modes/modes-plugin.ts +24 -0
- package/src/core/modules/delegation/delegator.ts +4 -4
- package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
- package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
- package/src/core/modules/host-shell/shell-lexer.ts +493 -0
- package/src/core/modules/host-shell/shell-paths.ts +85 -4
- package/src/core/modules/host-shell/shell-policy.ts +341 -332
- package/src/core/modules/host-shell/shell-programs.ts +298 -0
- package/src/core/modules/host-shell/shell-quoting.ts +237 -0
- package/src/core/modules/host-shell/shell-scripts.ts +114 -0
- package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
- package/src/core/modules/schedules/precheck-tools.ts +17 -0
- package/src/core/modules/schedules/schedules.ts +9 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/presence/direct-channels.ts +30 -0
- package/src/core/presentation/reply-body.ts +25 -0
- package/src/core/registry/contributions.ts +31 -7
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/conversation-turns.ts +2 -15
- package/src/core/routing/surface-port.ts +5 -1
- package/src/core/runtime/extensions/approval-card.ts +1 -5
- package/src/core/runtime/extensions/approval-fields.ts +3 -7
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
- package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
- package/src/core/runtime/extensions/gate-refusals.ts +23 -0
- package/src/core/runtime/extensions/held-calls.ts +16 -1
- package/src/core/runtime/extensions/hold-describer.ts +58 -8
- package/src/core/runtime/extensions/hold-lookups.ts +19 -19
- package/src/core/runtime/extensions/yolo.ts +105 -0
- package/src/core/runtime/pi-agent-runtime.ts +3 -5
- package/src/core/runtime/runtime-plugin.ts +42 -1
- package/src/core/runtime/runtime-types.ts +11 -0
- package/src/core/runtime/turn-budget.ts +32 -5
- package/src/core/runtime/turn-limits.ts +14 -3
- package/src/core/services.ts +61 -9
- package/src/core/shared/attachment-reader.ts +6 -0
- package/src/core/shared/unix-server.ts +10 -1
- package/src/core/testing/modules.ts +12 -8
- package/src/core/testing/surface-threads.ts +58 -0
- package/src/core/testing/thread-host.ts +17 -5
- package/src/core/threads/dispatch-threads.ts +299 -0
- package/src/core/threads/host-threads.ts +19 -0
- package/src/core/tool-set.snapshot.json +5 -0
- package/src/index.ts +27 -1
- package/src/kit/characters.ts +5 -0
- package/src/kit/index.ts +2 -0
- package/src/kit/shell.ts +1 -0
- package/src/kit/support.ts +1 -1
- package/src/kit/threads.ts +1 -1
- package/src/testing.ts +4 -0
- package/templates/adapters/web/package.json.tmpl +1 -1
- package/templates/agents.ts +1 -1
- package/templates/package.json.tmpl +1 -1
- package/src/core/discord/dispatch-threads.ts +0 -194
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { assistantName } from "../../i18n/index.ts";
|
|
2
|
+
import { ownPush, type PushPolicy } from "./git-push.ts";
|
|
3
|
+
import { deviceTarget, type LineState, writable } from "./shell-paths.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The hold classes of the shell rule, as `shell.<class>` ids that `HoldRule.holdClasses` lists for
|
|
7
|
+
* every command a call holds. Yolo mode approves `shell.write` (a write outside the scratch roots)
|
|
8
|
+
* and `shell.push` (a plain `git push` the push policy holds), and cards the rest unless
|
|
9
|
+
* `autonomy.yolo.allow` lists them. `shell.unreadable` is a line the rule could not read to the
|
|
10
|
+
* end, so it holds what it cannot tell apart from a destructive command.
|
|
11
|
+
*/
|
|
12
|
+
export const SHELL_CLASSES = Object.freeze([
|
|
13
|
+
"shell.privilege",
|
|
14
|
+
"shell.disk",
|
|
15
|
+
"shell.power",
|
|
16
|
+
"shell.kill",
|
|
17
|
+
"shell.rm",
|
|
18
|
+
"shell.write",
|
|
19
|
+
"shell.service",
|
|
20
|
+
"shell.docker",
|
|
21
|
+
"shell.firewall",
|
|
22
|
+
"shell.packages",
|
|
23
|
+
"shell.push",
|
|
24
|
+
"shell.force-push",
|
|
25
|
+
"shell.reset-hard",
|
|
26
|
+
"shell.gh",
|
|
27
|
+
"shell.crontab",
|
|
28
|
+
"shell.unreadable",
|
|
29
|
+
] as const);
|
|
30
|
+
|
|
31
|
+
export type ShellClass = (typeof SHELL_CLASSES)[number];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The shell classes yolo mode never approves unless `autonomy.yolo.allow` lists them: all of them
|
|
35
|
+
* but `shell.write` and `shell.push`, the non-destructive ones. A plain push to `main` deploys, so
|
|
36
|
+
* list `shell.push` in `autonomy.yolo.card` to keep it behind a card.
|
|
37
|
+
*/
|
|
38
|
+
export const YOLO_CARDED_SHELL_CLASSES: readonly string[] = Object.freeze(
|
|
39
|
+
SHELL_CLASSES.filter((id) => id !== "shell.write" && id !== "shell.push"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Why a command is held, and the class of that reason. */
|
|
43
|
+
export interface Held {
|
|
44
|
+
reason: string;
|
|
45
|
+
class: ShellClass;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SYSTEMD_VERBS = new Set([
|
|
49
|
+
"start",
|
|
50
|
+
"stop",
|
|
51
|
+
"restart",
|
|
52
|
+
"reload",
|
|
53
|
+
"try-restart",
|
|
54
|
+
"reload-or-restart",
|
|
55
|
+
"enable",
|
|
56
|
+
"disable",
|
|
57
|
+
"mask",
|
|
58
|
+
"unmask",
|
|
59
|
+
"kill",
|
|
60
|
+
"isolate",
|
|
61
|
+
]);
|
|
62
|
+
const DOCKER_VERBS = new Set(["stop", "kill", "restart", "rm", "rmi", "prune"]);
|
|
63
|
+
const DOCKER_OBJECTS = new Set([
|
|
64
|
+
"container",
|
|
65
|
+
"image",
|
|
66
|
+
"volume",
|
|
67
|
+
"network",
|
|
68
|
+
"system",
|
|
69
|
+
"builder",
|
|
70
|
+
]);
|
|
71
|
+
const COMPOSE_VERBS = new Set(["down", "stop", "kill", "restart", "rm"]);
|
|
72
|
+
const PACKAGE_VERBS = new Set([
|
|
73
|
+
"install",
|
|
74
|
+
"reinstall",
|
|
75
|
+
"remove",
|
|
76
|
+
"purge",
|
|
77
|
+
"autoremove",
|
|
78
|
+
"upgrade",
|
|
79
|
+
"full-upgrade",
|
|
80
|
+
"dist-upgrade",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/** Whether a push argument forces, deletes or mirrors refs rather than adding commits to them. */
|
|
84
|
+
function rewritesRemote(arg: string): boolean {
|
|
85
|
+
return (
|
|
86
|
+
arg === "--mirror" ||
|
|
87
|
+
arg === "--prune" ||
|
|
88
|
+
arg === "--delete" ||
|
|
89
|
+
arg === "-d" ||
|
|
90
|
+
arg.startsWith("+") ||
|
|
91
|
+
arg.startsWith(":")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Why a program that is held only with some arguments is, when it is; each has a single reason. */
|
|
96
|
+
export function verbHeld(
|
|
97
|
+
name: string,
|
|
98
|
+
args: string[],
|
|
99
|
+
prefix: string[],
|
|
100
|
+
state: LineState,
|
|
101
|
+
policy: PushPolicy,
|
|
102
|
+
): Held | undefined {
|
|
103
|
+
const verbs = args.filter((a) => !a.startsWith("-"));
|
|
104
|
+
const because = (
|
|
105
|
+
held: boolean,
|
|
106
|
+
reason: string,
|
|
107
|
+
heldClass: ShellClass,
|
|
108
|
+
): Held | undefined => (held ? { reason, class: heldClass } : undefined);
|
|
109
|
+
switch (name) {
|
|
110
|
+
case "systemctl":
|
|
111
|
+
return because(
|
|
112
|
+
verbs.some((v) => SYSTEMD_VERBS.has(v)),
|
|
113
|
+
"changes a systemd unit",
|
|
114
|
+
"shell.service",
|
|
115
|
+
);
|
|
116
|
+
case "service":
|
|
117
|
+
return because(
|
|
118
|
+
verbs.some((v) => ["start", "stop", "restart", "reload"].includes(v)),
|
|
119
|
+
"changes a service",
|
|
120
|
+
"shell.service",
|
|
121
|
+
);
|
|
122
|
+
case "docker": {
|
|
123
|
+
const [first, second] = verbs;
|
|
124
|
+
if (first === "compose")
|
|
125
|
+
return because(
|
|
126
|
+
second !== undefined && COMPOSE_VERBS.has(second),
|
|
127
|
+
`docker compose ${second}`,
|
|
128
|
+
"shell.docker",
|
|
129
|
+
);
|
|
130
|
+
if (first && DOCKER_VERBS.has(first))
|
|
131
|
+
return { reason: `docker ${first}`, class: "shell.docker" };
|
|
132
|
+
return because(
|
|
133
|
+
first !== undefined &&
|
|
134
|
+
DOCKER_OBJECTS.has(first) &&
|
|
135
|
+
second !== undefined &&
|
|
136
|
+
(DOCKER_VERBS.has(second) || second === "remove"),
|
|
137
|
+
`docker ${first} ${second}`,
|
|
138
|
+
"shell.docker",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
case "docker-compose":
|
|
142
|
+
return because(
|
|
143
|
+
verbs[0] !== undefined && COMPOSE_VERBS.has(verbs[0]),
|
|
144
|
+
`docker-compose ${verbs[0]}`,
|
|
145
|
+
"shell.docker",
|
|
146
|
+
);
|
|
147
|
+
case "ufw":
|
|
148
|
+
return because(
|
|
149
|
+
!(verbs[0] === "status" || verbs.length === 0),
|
|
150
|
+
"changes the firewall",
|
|
151
|
+
"shell.firewall",
|
|
152
|
+
);
|
|
153
|
+
case "iptables":
|
|
154
|
+
case "ip6tables":
|
|
155
|
+
case "nft":
|
|
156
|
+
return because(
|
|
157
|
+
!args.some((a) => ["-L", "--list", "-S", "list"].includes(a)),
|
|
158
|
+
"changes the firewall",
|
|
159
|
+
"shell.firewall",
|
|
160
|
+
);
|
|
161
|
+
case "apt":
|
|
162
|
+
case "apt-get":
|
|
163
|
+
case "snap":
|
|
164
|
+
return because(
|
|
165
|
+
verbs.some((v) => PACKAGE_VERBS.has(v)),
|
|
166
|
+
"installs or removes packages",
|
|
167
|
+
"shell.packages",
|
|
168
|
+
);
|
|
169
|
+
case "dpkg":
|
|
170
|
+
return because(
|
|
171
|
+
args.some((a) =>
|
|
172
|
+
["-i", "-r", "-P", "--install", "--remove", "--purge"].includes(a),
|
|
173
|
+
),
|
|
174
|
+
"installs or removes packages",
|
|
175
|
+
"shell.packages",
|
|
176
|
+
);
|
|
177
|
+
case "git":
|
|
178
|
+
return gitHeld(args, prefix, state, policy);
|
|
179
|
+
case "gh": {
|
|
180
|
+
const reason = ghHeld(args);
|
|
181
|
+
return reason ? { reason, class: "shell.gh" } : undefined;
|
|
182
|
+
}
|
|
183
|
+
case "crontab":
|
|
184
|
+
return because(args.includes("-r"), "removes a crontab", "shell.crontab");
|
|
185
|
+
case "tee": {
|
|
186
|
+
const target = args.find(
|
|
187
|
+
(a) => !a.startsWith("-") && !writable(a, state),
|
|
188
|
+
);
|
|
189
|
+
return target
|
|
190
|
+
? {
|
|
191
|
+
reason: `writes to ${target}`,
|
|
192
|
+
class: deviceTarget(target, state) ? "shell.disk" : "shell.write",
|
|
193
|
+
}
|
|
194
|
+
: undefined;
|
|
195
|
+
}
|
|
196
|
+
default:
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Why a `git` command is held: a push the policy does not let run, or a hard reset. */
|
|
202
|
+
function gitHeld(
|
|
203
|
+
args: string[],
|
|
204
|
+
prefix: string[],
|
|
205
|
+
state: LineState,
|
|
206
|
+
policy: PushPolicy,
|
|
207
|
+
): Held | undefined {
|
|
208
|
+
const [globals, [verb, ...rest]] = gitCommand(args);
|
|
209
|
+
if (verb === "push") {
|
|
210
|
+
const plain = !prefix.some((w) => w.startsWith("GIT_"));
|
|
211
|
+
if (plain && ownPush(globals, rest, state, policy)) return undefined;
|
|
212
|
+
if (
|
|
213
|
+
rest.some(
|
|
214
|
+
(a) => /^-[A-Za-z]*f[A-Za-z]*$/.test(a) || a.startsWith("--force"),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
return { reason: "force-pushes", class: "shell.force-push" };
|
|
218
|
+
if (rest.some(rewritesRemote))
|
|
219
|
+
return {
|
|
220
|
+
reason: "rewrites or deletes remote refs",
|
|
221
|
+
class: "shell.force-push",
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
reason: `pushes to GitHub, which deploys ${assistantName()} when the branch is main`,
|
|
225
|
+
class: "shell.push",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (verb === "reset" && rest.includes("--hard"))
|
|
229
|
+
return { reason: "git reset --hard", class: "shell.reset-hard" };
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Git's global options that take a value, as separate words. */
|
|
234
|
+
const GIT_VALUE_OPTIONS = new Set([
|
|
235
|
+
"-C",
|
|
236
|
+
"-c",
|
|
237
|
+
"--git-dir",
|
|
238
|
+
"--work-tree",
|
|
239
|
+
"--namespace",
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
/** Git's global options, such as `-C <dir>`, and the subcommand with its arguments. */
|
|
243
|
+
function gitCommand(args: string[]): [string[], string[]] {
|
|
244
|
+
let i = 0;
|
|
245
|
+
while (i < args.length && (args[i] as string).startsWith("-"))
|
|
246
|
+
i += GIT_VALUE_OPTIONS.has(args[i] as string) ? 2 : 1;
|
|
247
|
+
return [args.slice(0, i), args.slice(i)];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const GH_BODY_FLAGS = ["-f", "-F", "--field", "--raw-field", "--input"];
|
|
251
|
+
const GH_READ_VERBS = new Set(["list", "view", "download"]);
|
|
252
|
+
const GH_REPO_CHANGES = new Set([
|
|
253
|
+
"create",
|
|
254
|
+
"delete",
|
|
255
|
+
"edit",
|
|
256
|
+
"rename",
|
|
257
|
+
"archive",
|
|
258
|
+
"unarchive",
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
/** Why a `gh` command writes to GitHub in a way the owner approves, or undefined. */
|
|
262
|
+
function ghHeld(args: string[]): string | undefined {
|
|
263
|
+
const [group, verb] = args.filter((a) => !a.startsWith("-"));
|
|
264
|
+
switch (group) {
|
|
265
|
+
case "pr":
|
|
266
|
+
return verb === "merge" ? "merges a pull request" : undefined;
|
|
267
|
+
case "api": {
|
|
268
|
+
const body = args.some((a) =>
|
|
269
|
+
GH_BODY_FLAGS.some((f) => a === f || a.startsWith(`${f}=`)),
|
|
270
|
+
);
|
|
271
|
+
if (verb === "graphql")
|
|
272
|
+
return args.some((a) => /\bmutation\b/.test(a))
|
|
273
|
+
? "changes GitHub through the API"
|
|
274
|
+
: undefined;
|
|
275
|
+
const index = args.findIndex((a) => a === "-X" || a === "--method");
|
|
276
|
+
const method = (
|
|
277
|
+
index === -1
|
|
278
|
+
? args.find((a) => a.startsWith("--method="))?.slice(9)
|
|
279
|
+
: args[index + 1]
|
|
280
|
+
)?.toUpperCase();
|
|
281
|
+
return body || (method !== undefined && method !== "GET")
|
|
282
|
+
? "changes GitHub through the API"
|
|
283
|
+
: undefined;
|
|
284
|
+
}
|
|
285
|
+
case "release":
|
|
286
|
+
case "secret":
|
|
287
|
+
case "variable":
|
|
288
|
+
return verb && !GH_READ_VERBS.has(verb)
|
|
289
|
+
? `changes GitHub ${group}s`
|
|
290
|
+
: undefined;
|
|
291
|
+
case "repo":
|
|
292
|
+
return verb && GH_REPO_CHANGES.has(verb)
|
|
293
|
+
? `changes a GitHub repository (${verb})`
|
|
294
|
+
: undefined;
|
|
295
|
+
default:
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/** Reading the quoted and delimited pieces of a command line that do not nest: `$'…'` and here-documents. */
|
|
2
|
+
|
|
3
|
+
/** Whether a line continuation follows a character that starts a longer token (`$\⏎(`, `<<\⏎EOF`). */
|
|
4
|
+
export function continuedToken(src: string): boolean {
|
|
5
|
+
return /[$<>{,(]\\\n/.test(src);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether the `}` at `pos` closes a group: a word of its own after a separator. Any other `{` or
|
|
10
|
+
* `}` is part of a word, which may be a brace expansion such as `{sudo,ls}`.
|
|
11
|
+
*/
|
|
12
|
+
export function closesGroup(src: string, pos: number): boolean {
|
|
13
|
+
const before = src[pos - 1];
|
|
14
|
+
const after = src[pos + 1];
|
|
15
|
+
const space = (ch: string) => /\s/.test(ch);
|
|
16
|
+
return (
|
|
17
|
+
(before === undefined || space(before) || ";&|()".includes(before)) &&
|
|
18
|
+
(after === undefined || space(after) || ";&|()<>".includes(after))
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A line the lexer cannot place; the rule holds it whole. */
|
|
23
|
+
export class Unreadable extends Error {}
|
|
24
|
+
|
|
25
|
+
/** The escapes of `$'…'` that stand for one fixed character. */
|
|
26
|
+
const ANSI_ESCAPES: Readonly<Record<string, string>> = {
|
|
27
|
+
a: "\x07",
|
|
28
|
+
b: "\b",
|
|
29
|
+
e: "\x1b",
|
|
30
|
+
E: "\x1b",
|
|
31
|
+
f: "\f",
|
|
32
|
+
n: "\n",
|
|
33
|
+
r: "\r",
|
|
34
|
+
t: "\t",
|
|
35
|
+
v: "\v",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The text of the `$'…'` string at `pos`, with the escapes bash decodes (`$'\x73udo'` is `sudo`), and where it ends. */
|
|
39
|
+
export function ansiQuoted(
|
|
40
|
+
src: string,
|
|
41
|
+
pos: number,
|
|
42
|
+
): { text: string; end: number } {
|
|
43
|
+
let text = "";
|
|
44
|
+
let i = pos + 2;
|
|
45
|
+
while (i < src.length && src[i] !== "'") {
|
|
46
|
+
if (src[i] !== "\\" || i + 1 >= src.length) {
|
|
47
|
+
text += src[i];
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const c = src[i + 1] as string;
|
|
52
|
+
i += 2;
|
|
53
|
+
const simple = ANSI_ESCAPES[c];
|
|
54
|
+
if (simple !== undefined) text += simple;
|
|
55
|
+
else if (/[0-7]/.test(c)) {
|
|
56
|
+
const digits = /^[0-7]{0,2}/.exec(src.slice(i, i + 2))?.[0] ?? "";
|
|
57
|
+
text += String.fromCharCode(Number.parseInt(c + digits, 8) & 0xff);
|
|
58
|
+
i += digits.length;
|
|
59
|
+
} else if (c === "x" || c === "u" || c === "U") {
|
|
60
|
+
const max = c === "x" ? 2 : c === "u" ? 4 : 8;
|
|
61
|
+
const hex = /^[0-9a-fA-F]*/.exec(src.slice(i, i + max))?.[0] ?? "";
|
|
62
|
+
if (hex === "") text += `\\${c}`;
|
|
63
|
+
else {
|
|
64
|
+
const code = Number.parseInt(hex, 16);
|
|
65
|
+
text += code <= 0x10ffff ? String.fromCodePoint(code) : "";
|
|
66
|
+
i += hex.length;
|
|
67
|
+
}
|
|
68
|
+
} else if (c === "c" && i < src.length)
|
|
69
|
+
text += String.fromCharCode((src.charCodeAt(i++) as number) & 0x1f);
|
|
70
|
+
else if ("\\'\"?".includes(c)) text += c;
|
|
71
|
+
// Any other escape stays as it is, backslash and all: `$'s\⏎udo'` is `s\⏎udo`, which `eval` joins.
|
|
72
|
+
else text += `\\${c}`;
|
|
73
|
+
}
|
|
74
|
+
// A NUL ends the string: `$'sudo\0x'` is `sudo`.
|
|
75
|
+
const nul = text.indexOf("\0");
|
|
76
|
+
return {
|
|
77
|
+
text: nul === -1 ? text : text.slice(0, nul),
|
|
78
|
+
end: Math.min(i + 1, src.length),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The here-document delimiter at `pos`, with its quotes removed, and where its word ends. */
|
|
83
|
+
export function readDelimiter(
|
|
84
|
+
src: string,
|
|
85
|
+
pos: number,
|
|
86
|
+
): { text: string; quoted: boolean; end: number } {
|
|
87
|
+
let text = "";
|
|
88
|
+
let quoted = false;
|
|
89
|
+
let i = pos;
|
|
90
|
+
while (i < src.length) {
|
|
91
|
+
const c = src[i] as string;
|
|
92
|
+
// Bash ends the word at a blank or newline only: `EOF\r` is a delimiter of its own.
|
|
93
|
+
if (" \t\n;&|()<>".includes(c)) break;
|
|
94
|
+
if (c === "'") {
|
|
95
|
+
const close = src.indexOf("'", i + 1);
|
|
96
|
+
const stop = close === -1 ? src.length : close;
|
|
97
|
+
text += src.slice(i + 1, stop);
|
|
98
|
+
i = stop + 1;
|
|
99
|
+
quoted = true;
|
|
100
|
+
} else if (c === '"') {
|
|
101
|
+
for (i++; i < src.length && src[i] !== '"'; i++) {
|
|
102
|
+
if (src[i] === "\\" && src[i + 1] === "\n") throw new Unreadable();
|
|
103
|
+
if (
|
|
104
|
+
src[i] === "\\" &&
|
|
105
|
+
i + 1 < src.length &&
|
|
106
|
+
'"\\$`'.includes(src[i + 1] as string)
|
|
107
|
+
)
|
|
108
|
+
i++;
|
|
109
|
+
text += src[i];
|
|
110
|
+
}
|
|
111
|
+
i++;
|
|
112
|
+
quoted = true;
|
|
113
|
+
} else if (c === "\\") {
|
|
114
|
+
// Bash joins a backslash and a newline out of the word; no one writes one in a delimiter.
|
|
115
|
+
if (src[i + 1] === "\n") throw new Unreadable();
|
|
116
|
+
if (i + 1 < src.length) text += src[i + 1];
|
|
117
|
+
i += 2;
|
|
118
|
+
quoted = true;
|
|
119
|
+
} else if (c === "$" && src[i + 1] === "'") {
|
|
120
|
+
const found = ansiQuoted(src, i);
|
|
121
|
+
text += found.text;
|
|
122
|
+
i = found.end;
|
|
123
|
+
quoted = true;
|
|
124
|
+
} else {
|
|
125
|
+
text += c;
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// `<<` followed by nothing, by an operator, or by `""` names no delimiter: bash versions end
|
|
130
|
+
// such a body, and the substitution around it, in different places.
|
|
131
|
+
if (text === "") throw new Unreadable();
|
|
132
|
+
return { text, quoted, end: Math.min(i, src.length) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Where the body of the here-document that starts at `start` ends (`bodyEnd`) and where the text
|
|
137
|
+
* after its delimiter line begins (`next`). Without a delimiter line the body runs to the end.
|
|
138
|
+
*/
|
|
139
|
+
export function heredocBody(
|
|
140
|
+
src: string,
|
|
141
|
+
start: number,
|
|
142
|
+
here: { delimiter: string; quoted: boolean; stripTabs: boolean },
|
|
143
|
+
): { bodyEnd: number; next: number; terminated: boolean } {
|
|
144
|
+
let at = start;
|
|
145
|
+
// Joining continuation lines is bounded by the delimiter length per start and by a total step
|
|
146
|
+
// budget; a body that exhausts it is unreadable (held) instead of quadratic.
|
|
147
|
+
let budget = 4 * src.length + 1000;
|
|
148
|
+
const limit = here.delimiter.length;
|
|
149
|
+
while (at < src.length) {
|
|
150
|
+
const nl = src.indexOf("\n", at);
|
|
151
|
+
const lineEnd = nl === -1 ? src.length : nl;
|
|
152
|
+
const line = src.slice(at, lineEnd);
|
|
153
|
+
if ((here.stripTabs ? line.replace(/^\t+/, "") : line) === here.delimiter)
|
|
154
|
+
return {
|
|
155
|
+
bodyEnd: at,
|
|
156
|
+
next: nl === -1 ? src.length : nl + 1,
|
|
157
|
+
terminated: true,
|
|
158
|
+
};
|
|
159
|
+
// A continued line may join the delimiter line; bash and this lexer might disagree.
|
|
160
|
+
if (!here.quoted) {
|
|
161
|
+
// `kept` is the joined text as the delimiter compares it (leading tabs dropped for `<<-`),
|
|
162
|
+
// grown by appending, so each join costs only the new piece.
|
|
163
|
+
const strip = (t: string) => (here.stripTabs ? t.replace(/^\t+/, "") : t);
|
|
164
|
+
let kept = strip(line);
|
|
165
|
+
let end = lineEnd;
|
|
166
|
+
while (/(?:^|[^\\])(?:\\\\)*\\$/.test(kept) && end < src.length) {
|
|
167
|
+
if (--budget < 0) throw new Unreadable();
|
|
168
|
+
const stop = src.indexOf("\n", end + 1);
|
|
169
|
+
const nextEnd = stop === -1 ? src.length : stop;
|
|
170
|
+
const piece = src.slice(end + 1, nextEnd);
|
|
171
|
+
kept = kept.slice(0, -1) + (kept.length === 1 ? strip(piece) : piece);
|
|
172
|
+
end = nextEnd;
|
|
173
|
+
// Past the delimiter length, or once the text before a continuing backslash is no
|
|
174
|
+
// longer a prefix of the delimiter, no join can match.
|
|
175
|
+
const base = kept.endsWith("\\") ? kept.slice(0, -1) : kept;
|
|
176
|
+
if (base.length > limit || !here.delimiter.startsWith(base)) break;
|
|
177
|
+
if (kept === here.delimiter) throw new Unreadable();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (nl === -1) break;
|
|
181
|
+
at = nl + 1;
|
|
182
|
+
}
|
|
183
|
+
return { bodyEnd: src.length, next: src.length, terminated: false };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The text of the backtick substitution at `start`, with the escapes bash takes out of it: a
|
|
188
|
+
* backslash before a backtick, a backslash or `$`, and, inside double quotes, before `"`.
|
|
189
|
+
*/
|
|
190
|
+
export function backtickBody(
|
|
191
|
+
src: string,
|
|
192
|
+
start: number,
|
|
193
|
+
inDouble: boolean,
|
|
194
|
+
): { inner: string; end: number } {
|
|
195
|
+
const escapable = inDouble ? '`\\$"' : "`\\$";
|
|
196
|
+
let inner = "";
|
|
197
|
+
let i = start + 1;
|
|
198
|
+
for (; i < src.length && src[i] !== "`"; i++) {
|
|
199
|
+
const next = src[i + 1];
|
|
200
|
+
// Bash joins a backslash and a newline in a backtick body before it reads the body.
|
|
201
|
+
if (src[i] === "\\" && next === "\n") throw new Unreadable();
|
|
202
|
+
if (src[i] === "\\" && next && escapable.includes(next)) i++;
|
|
203
|
+
inner += src[i];
|
|
204
|
+
}
|
|
205
|
+
return { inner, end: Math.min(i + 1, src.length) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The redirection operator at `pos` (`>`, `>>`, `<`, `<>`, `>|`, `>&2`, `<&0`) as the word the lexer gives it, where it ends, and whether it closes a descriptor (`>&-`). */
|
|
209
|
+
export function redirectOperator(
|
|
210
|
+
src: string,
|
|
211
|
+
pos: number,
|
|
212
|
+
): { op: string; end: number; closes: boolean } {
|
|
213
|
+
let op = src[pos] as string;
|
|
214
|
+
let closes = false;
|
|
215
|
+
let end = pos + 1;
|
|
216
|
+
if (src[end] === ">") {
|
|
217
|
+
op += ">";
|
|
218
|
+
end++;
|
|
219
|
+
}
|
|
220
|
+
if (op === "<>") op = ">"; // opens the file for writing too
|
|
221
|
+
if (op === ">" && src[end] === "|") end++; // `>|` forces the overwrite
|
|
222
|
+
if (src[end] === "&") {
|
|
223
|
+
const digit = /\s*[0-9-]/y.test(src.slice(end + 1, end + 65));
|
|
224
|
+
const blank = " \t\n".includes(src[end + 1] ?? "\n");
|
|
225
|
+
// `<& file` names no descriptor; `<& 0` and `<& -` do, as `<&0` does.
|
|
226
|
+
if (op === "<" && blank && !digit) return { op, end, closes };
|
|
227
|
+
end++;
|
|
228
|
+
// `>&2`, `>&-` and `<&0` duplicate or close a descriptor; `>& file` names a file.
|
|
229
|
+
if (op === "<" || digit) op = "dup";
|
|
230
|
+
// `>&-` closes the descriptor: the `-` is its whole operand, and what follows it directly is the next word.
|
|
231
|
+
if (op === "dup" && src[end] === "-") {
|
|
232
|
+
end++;
|
|
233
|
+
closes = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { op, end, closes };
|
|
237
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { LineState } from "./shell-paths.ts";
|
|
3
|
+
|
|
4
|
+
/** Shells that run the command given with `-c`, or a script from a file or standard input. */
|
|
5
|
+
export const SHELLS: ReadonlySet<string> = new Set([
|
|
6
|
+
"bash",
|
|
7
|
+
"sh",
|
|
8
|
+
"zsh",
|
|
9
|
+
"dash",
|
|
10
|
+
"ksh",
|
|
11
|
+
"ash",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Shell options that take the next word as their argument. */
|
|
15
|
+
const SHELL_OPTIONS_WITH_ARGUMENT = new Set([
|
|
16
|
+
"-o",
|
|
17
|
+
"+o",
|
|
18
|
+
"-O",
|
|
19
|
+
"+O",
|
|
20
|
+
"--rcfile",
|
|
21
|
+
"--init-file",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** The command a shell runs from its arguments (`-c`, also in a cluster such as `-ec`), if it is given one. */
|
|
25
|
+
export function shellScript(args: readonly string[]): string | undefined {
|
|
26
|
+
for (let i = 0; i < args.length; i++) {
|
|
27
|
+
const arg = args[i] as string;
|
|
28
|
+
if (SHELL_OPTIONS_WITH_ARGUMENT.has(arg)) i++;
|
|
29
|
+
else if (/^-[A-Za-z]*c[A-Za-z]*$/.test(arg)) return args[i + 1];
|
|
30
|
+
else if (!/^[-+]/.test(arg) || arg === "--") return undefined;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Files that stand for the script text another command or a here-document gives on standard input, and a process substitution. */
|
|
36
|
+
const STDIN_FILE = /^(?:\/(?:dev\/(?:stdin|fd\/|tty)|proc\/)|[<>]\()/;
|
|
37
|
+
|
|
38
|
+
/** A written path as the line names it, without a leading `./`. */
|
|
39
|
+
const normal = (path: string): string => path.replace(/^(?:\.\/)+/, "");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How a shell gets its script when it has no `-c`: from standard input (`-s`, `-`, or no operand at
|
|
43
|
+
* all), or from the file it is given, which is its first word that is not an option.
|
|
44
|
+
*/
|
|
45
|
+
function scriptSource(args: readonly string[]): {
|
|
46
|
+
stdin: boolean;
|
|
47
|
+
file?: string;
|
|
48
|
+
} {
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const arg = args[i] as string;
|
|
51
|
+
if (SHELL_OPTIONS_WITH_ARGUMENT.has(arg)) i++;
|
|
52
|
+
else if (arg === "--")
|
|
53
|
+
return args[i + 1] === undefined
|
|
54
|
+
? { stdin: true }
|
|
55
|
+
: { stdin: false, file: args[i + 1] };
|
|
56
|
+
else if (arg === "-") return { stdin: true };
|
|
57
|
+
else if (/^-[A-Za-z]*s[A-Za-z]*$/.test(arg)) return { stdin: true };
|
|
58
|
+
else if (!/^[-+]/.test(arg)) return { stdin: false, file: arg };
|
|
59
|
+
}
|
|
60
|
+
return { stdin: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a command runs a script that the line does not show in a word of its own: a shell with
|
|
65
|
+
* no `-c` script reads its commands from standard input (a pipe, a here-document, a here-string,
|
|
66
|
+
* a process substitution) or from a file the line wrote; `source` and `.` do the same; a program
|
|
67
|
+
* the line wrote runs. A shell that `xargs` starts takes its script or file from the input.
|
|
68
|
+
* Running a script file that the line did not write is no different from running any program.
|
|
69
|
+
*/
|
|
70
|
+
export function readsUnseenScript(
|
|
71
|
+
word: string,
|
|
72
|
+
name: string,
|
|
73
|
+
args: readonly string[],
|
|
74
|
+
prefix: readonly string[],
|
|
75
|
+
state: LineState,
|
|
76
|
+
): boolean {
|
|
77
|
+
const fromInput = prefix.includes("xargs");
|
|
78
|
+
const written = (path: string | undefined): boolean =>
|
|
79
|
+
path !== undefined && state.written.has(normal(path));
|
|
80
|
+
if (SHELLS.has(name)) {
|
|
81
|
+
if (shellScript(args) !== undefined) return false;
|
|
82
|
+
const { stdin, file } = scriptSource(args);
|
|
83
|
+
return (
|
|
84
|
+
fromInput ||
|
|
85
|
+
stdin ||
|
|
86
|
+
(file !== undefined && (STDIN_FILE.test(file) || written(file)))
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (name === "source" || name === ".") {
|
|
90
|
+
const [file] = args;
|
|
91
|
+
return (
|
|
92
|
+
fromInput || file === undefined || STDIN_FILE.test(file) || written(file)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return /^\.{0,2}\//.test(word) && written(word);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Notes the files a command writes: the targets of `>` and `>>`, and the operands of `tee`. */
|
|
99
|
+
export function recordWrites(
|
|
100
|
+
words: readonly string[],
|
|
101
|
+
teeOperands: readonly string[],
|
|
102
|
+
state: LineState,
|
|
103
|
+
): void {
|
|
104
|
+
for (let i = 0; i < words.length - 1; i++)
|
|
105
|
+
if (words[i] === ">" || words[i] === ">>")
|
|
106
|
+
state.written.add(normal(words[i + 1] as string));
|
|
107
|
+
for (const operand of teeOperands)
|
|
108
|
+
if (!operand.startsWith("-")) state.written.add(normal(operand));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The operands of `tee` in a command, or none when it is another program. */
|
|
112
|
+
export function teeOperands(name: string, args: readonly string[]): string[] {
|
|
113
|
+
return basename(name) === "tee" ? [...args] : [];
|
|
114
|
+
}
|