pi-roundtable 0.7.15 → 0.7.17
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 +36 -0
- package/docs/plugins.md +94 -4
- package/package.json +2 -1
- package/src/core/agents/agent-ports.ts +9 -0
- package/src/core/agents/agent-prompt.ts +3 -1
- package/src/core/agents/agent-team.ts +2 -0
- package/src/core/agents/team-options.ts +2 -0
- package/src/core/agents/team-turn-types.ts +1 -1
- package/src/core/agents/team-turns.ts +24 -0
- package/src/core/builtin/agent-server.ts +37 -0
- package/src/core/config/config.ts +30 -3
- package/src/core/contract/runtime.ts +5 -0
- package/src/core/contract/surface.ts +8 -0
- package/src/core/define-roundtable.ts +3 -0
- package/src/core/discord/agent-discord.ts +33 -0
- package/src/core/discord/discord-surface.ts +20 -0
- package/src/core/domain/interim.ts +16 -0
- package/src/core/domain/ports.ts +6 -0
- package/src/core/holds.ts +2 -0
- package/src/core/modules/host-shell/git-push.ts +112 -0
- package/src/core/modules/host-shell/shell-paths.ts +160 -0
- package/src/core/modules/host-shell/shell-policy.ts +111 -48
- package/src/core/routing/conversation-turns.ts +3 -0
- package/src/core/routing/surface-port.ts +1 -0
- package/src/core/runtime/conversation-sessions.ts +7 -2
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +6 -7
- package/src/core/runtime/interim-text.ts +207 -0
- package/src/core/runtime/pi-agent-runtime.ts +13 -2
- package/src/core/runtime/prompt-slot.ts +13 -2
- package/src/core/runtime/runtime-types.ts +8 -0
- package/src/core/runtime/session-factory.ts +16 -0
- package/src/index.ts +5 -0
- package/src/kit/index.ts +20 -0
- package/src/kit/jev.ts +357 -0
- package/src/kit/shell.ts +2 -0
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
DashboardBoard,
|
|
24
24
|
} from "../agents/agent-ports.ts";
|
|
25
25
|
import { AgentError } from "../domain/errors.ts";
|
|
26
|
+
import type { InterimPosts } from "../domain/interim.ts";
|
|
26
27
|
import { messages } from "../i18n/index.ts";
|
|
27
28
|
import type { Logger } from "../log.ts";
|
|
28
29
|
|
|
@@ -69,6 +70,38 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
interim(
|
|
74
|
+
channelId: string,
|
|
75
|
+
as: Omit<AgentPost, "thinking" | "chunks" | "files">,
|
|
76
|
+
): InterimPosts {
|
|
77
|
+
const threadId = as.threadId ? { threadId: as.threadId } : {};
|
|
78
|
+
return {
|
|
79
|
+
post: async (text) => {
|
|
80
|
+
const webhook = await this.#webhook(channelId);
|
|
81
|
+
try {
|
|
82
|
+
const message = await webhook.send({
|
|
83
|
+
username: as.name,
|
|
84
|
+
avatarURL: as.avatarUrl,
|
|
85
|
+
allowedMentions: { parse: ["users"] },
|
|
86
|
+
...threadId,
|
|
87
|
+
content: text,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
edit: async (change) => {
|
|
91
|
+
await webhook.editMessage(message, {
|
|
92
|
+
content: change,
|
|
93
|
+
...threadId,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.#webhooks.delete(channelId);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
async createChannel(
|
|
73
106
|
name: string,
|
|
74
107
|
topic: string,
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
InboundMessage,
|
|
18
18
|
OutboundReply,
|
|
19
19
|
} from "../domain/conversation.ts";
|
|
20
|
+
import type { InterimPosts } from "../domain/interim.ts";
|
|
20
21
|
import type { OwnerPrompts } from "../domain/owner-prompts.ts";
|
|
21
22
|
import type { OwnerNotifier } from "../domain/ports.ts";
|
|
22
23
|
import { messages } from "../i18n/index.ts";
|
|
@@ -159,6 +160,25 @@ export class DiscordSurface
|
|
|
159
160
|
return this.#options.prompts(channel, speaker);
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
/** The channel's interim posts: ordinary messages, edited in place for the progress line. */
|
|
164
|
+
interim(channel: ChannelKey): InterimPosts | undefined {
|
|
165
|
+
if (!channel.startsWith(PREFIX)) return undefined;
|
|
166
|
+
return {
|
|
167
|
+
post: async (text) => {
|
|
168
|
+
const target = await this.#sendable(channel);
|
|
169
|
+
const message = await target.send({
|
|
170
|
+
content: text,
|
|
171
|
+
allowedMentions: { parse: ["users"] },
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
edit: async (change) => {
|
|
175
|
+
await message.edit({ content: change });
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
async start(onMessage: (message: InboundMessage) => void): Promise<void> {
|
|
163
183
|
const { logger, token } = this.#options;
|
|
164
184
|
this.#client.on(Events.MessageCreate, (message) => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A message an interim post made, which the turn edits in place as it goes. */
|
|
2
|
+
export interface InterimMessage {
|
|
3
|
+
edit(text: string): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a running turn posts the text it writes before its final answer: the channel the
|
|
8
|
+
* final reply goes to, under the same name. Each call posts one ordinary message of at most
|
|
9
|
+
* Discord's 2000 characters.
|
|
10
|
+
*/
|
|
11
|
+
export interface InterimPosts {
|
|
12
|
+
post(text: string): Promise<InterimMessage>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether a turn posts its intermediate text as it goes; "on" by default. */
|
|
16
|
+
export type InterimTextMode = "on" | "off";
|
package/src/core/domain/ports.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { AgentTurnScope, TurnSelection } from "../sessions.ts";
|
|
|
2
2
|
import type { Speaker } from "../speakers.ts";
|
|
3
3
|
import type { TurnAttachments } from "./attachment.ts";
|
|
4
4
|
import type { ChannelKey } from "./conversation.ts";
|
|
5
|
+
import type { InterimPosts } from "./interim.ts";
|
|
5
6
|
|
|
6
7
|
export type { AgentTurnScope };
|
|
7
8
|
|
|
@@ -32,6 +33,11 @@ export interface TurnRequest {
|
|
|
32
33
|
* schedule's turn is not.
|
|
33
34
|
*/
|
|
34
35
|
interactive?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Where the turn posts the text it writes before its final answer, as it goes; the caller
|
|
38
|
+
* hands the channel its reply goes to. Absent = only the final reply is posted.
|
|
39
|
+
*/
|
|
40
|
+
interim?: InterimPosts;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/** The judge's question and answer shapes. */
|
package/src/core/holds.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { type Tier, tierAtLeast } from "./speakers.ts";
|
|
|
5
5
|
export interface HoldContext {
|
|
6
6
|
/** The shared workspace of a session with a shell; calls reaching outside it may be held. */
|
|
7
7
|
workspace?: string;
|
|
8
|
+
/** The session's scratch dir, where its shell's TMPDIR points; writes and removals inside it run. */
|
|
9
|
+
scratchDir?: string;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { type LineState, resolvePath } from "./shell-paths.ts";
|
|
3
|
+
|
|
4
|
+
/** Which pushes run without the owner's approval; see `shellHoldRuleFor`. */
|
|
5
|
+
export interface PushPolicy {
|
|
6
|
+
/** GitHub owners whose repositories take a plain push without a hold. */
|
|
7
|
+
ownPushOwners?: readonly string[];
|
|
8
|
+
/** `owner/repo` names that stay held although their owner is listed. */
|
|
9
|
+
heldPushRepos?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
13
|
+
|
|
14
|
+
/** Push options that neither force, delete, nor push tags or more than the named refs. */
|
|
15
|
+
const PLAIN_FLAGS = new Set([
|
|
16
|
+
"-u",
|
|
17
|
+
"--set-upstream",
|
|
18
|
+
"-q",
|
|
19
|
+
"--quiet",
|
|
20
|
+
"-v",
|
|
21
|
+
"--verbose",
|
|
22
|
+
"-n",
|
|
23
|
+
"--dry-run",
|
|
24
|
+
"--no-verify",
|
|
25
|
+
"--verify",
|
|
26
|
+
"--atomic",
|
|
27
|
+
"--porcelain",
|
|
28
|
+
"--progress",
|
|
29
|
+
"--no-progress",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** `owner/repo` of a GitHub remote URL, https or ssh, or undefined. */
|
|
33
|
+
export function githubRepo(url: string): string | undefined {
|
|
34
|
+
const match =
|
|
35
|
+
/^(?:https:\/\/(?:[^@/]+@)?github\.com\/|ssh:\/\/git@github\.com(?::\d+)?\/|git@github\.com:)([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/i.exec(
|
|
36
|
+
url.trim(),
|
|
37
|
+
);
|
|
38
|
+
return match ? `${match[1]}/${match[2]}`.toLowerCase() : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function git(dir: string, args: readonly string[]): string | undefined {
|
|
42
|
+
try {
|
|
43
|
+
return execFileSync("git", ["-C", dir, ...args], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
timeout: GIT_TIMEOUT_MS,
|
|
46
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether `git <global> push <args>` is a plain push to one of the owner's own GitHub repositories:
|
|
55
|
+
* no force, deletion, tags or mirror, and a remote whose push URLs all belong to a listed owner.
|
|
56
|
+
*/
|
|
57
|
+
export function ownPush(
|
|
58
|
+
globals: readonly string[],
|
|
59
|
+
args: readonly string[],
|
|
60
|
+
state: LineState,
|
|
61
|
+
policy: PushPolicy,
|
|
62
|
+
): boolean {
|
|
63
|
+
const owners = (policy.ownPushOwners ?? []).map((o) => o.toLowerCase());
|
|
64
|
+
if (owners.length === 0) return false;
|
|
65
|
+
let dir = state.cwd;
|
|
66
|
+
for (let i = 0; i < globals.length; i++) {
|
|
67
|
+
// `-c`, `--git-dir` and the like can point the push elsewhere.
|
|
68
|
+
if (globals[i] !== "-C") return false;
|
|
69
|
+
const next = globals[++i];
|
|
70
|
+
if (next === undefined || dir === undefined) return false;
|
|
71
|
+
dir = resolvePath(next, { ...state, cwd: dir });
|
|
72
|
+
}
|
|
73
|
+
if (dir === undefined) return false;
|
|
74
|
+
const positional: string[] = [];
|
|
75
|
+
for (let i = 0; i < args.length; i++) {
|
|
76
|
+
const arg = args[i] as string;
|
|
77
|
+
if (arg === "-o" || arg === "--push-option") i++;
|
|
78
|
+
else if (arg.startsWith("--push-option=")) continue;
|
|
79
|
+
else if (arg.startsWith("-")) {
|
|
80
|
+
if (!PLAIN_FLAGS.has(arg)) return false;
|
|
81
|
+
} else positional.push(arg);
|
|
82
|
+
}
|
|
83
|
+
const [remote = "origin", ...refspecs] = positional;
|
|
84
|
+
for (const refspec of refspecs) {
|
|
85
|
+
if (refspec.startsWith("+") || refspec.startsWith(":")) return false;
|
|
86
|
+
if (refspec.includes("refs/tags/")) return false;
|
|
87
|
+
for (const ref of refspec.split(":"))
|
|
88
|
+
if (
|
|
89
|
+
ref &&
|
|
90
|
+
git(dir, ["show-ref", "--verify", "--quiet", `refs/tags/${ref}`]) !==
|
|
91
|
+
undefined
|
|
92
|
+
)
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const urls = /[:/]/.test(remote)
|
|
96
|
+
? [remote]
|
|
97
|
+
: git(dir, ["remote", "get-url", "--push", "--all", remote])
|
|
98
|
+
?.split("\n")
|
|
99
|
+
.filter(Boolean);
|
|
100
|
+
if (!urls || urls.length === 0) return false;
|
|
101
|
+
const held = new Set(
|
|
102
|
+
(policy.heldPushRepos ?? []).map((r) => r.toLowerCase()),
|
|
103
|
+
);
|
|
104
|
+
return urls.every((url) => {
|
|
105
|
+
const repo = githubRepo(url);
|
|
106
|
+
return (
|
|
107
|
+
repo !== undefined &&
|
|
108
|
+
owners.includes(repo.split("/")[0] as string) &&
|
|
109
|
+
!held.has(repo)
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import {
|
|
4
|
+
basename,
|
|
5
|
+
dirname,
|
|
6
|
+
isAbsolute,
|
|
7
|
+
join,
|
|
8
|
+
relative,
|
|
9
|
+
resolve,
|
|
10
|
+
} from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the commands of one command line know as they are read in order: the variables assigned
|
|
14
|
+
* earlier with literal values, and the directory the last `cd` moved to.
|
|
15
|
+
*/
|
|
16
|
+
export interface LineState {
|
|
17
|
+
/** The shared workspace, where a command line starts. */
|
|
18
|
+
workspace: string;
|
|
19
|
+
/** Directories a command writes and removes in without a hold: the workspace and the scratch dir. */
|
|
20
|
+
roots: readonly string[];
|
|
21
|
+
/** Variables with known values; a name mapped to undefined was assigned something unknown. */
|
|
22
|
+
vars: Map<string, string | undefined>;
|
|
23
|
+
/** The working directory, or undefined once a `cd` went somewhere unknown. */
|
|
24
|
+
cwd: string | undefined;
|
|
25
|
+
/** Whether `cd` can be followed: false in a line with subshells or `||`, where the last `cd` may not apply. */
|
|
26
|
+
followsCd: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function lineState(
|
|
30
|
+
command: string,
|
|
31
|
+
workspace: string,
|
|
32
|
+
scratchDir: string | undefined,
|
|
33
|
+
): LineState {
|
|
34
|
+
const vars = new Map<string, string | undefined>([["HOME", homedir()]]);
|
|
35
|
+
if (scratchDir !== undefined) vars.set("TMPDIR", scratchDir);
|
|
36
|
+
return {
|
|
37
|
+
workspace,
|
|
38
|
+
roots: scratchDir === undefined ? [workspace] : [workspace, scratchDir],
|
|
39
|
+
vars,
|
|
40
|
+
cwd: workspace,
|
|
41
|
+
followsCd: !/(^|[^$])\(|\|\|/.test(command),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The word with the line's variables substituted and `~` expanded, or undefined when it holds a
|
|
49
|
+
* command substitution, an unknown variable or `~user`.
|
|
50
|
+
*/
|
|
51
|
+
export function expand(word: string, state: LineState): string | undefined {
|
|
52
|
+
if (word.includes("`") || word.includes("$(")) return undefined;
|
|
53
|
+
let unknown = false;
|
|
54
|
+
const substituted = word.replace(
|
|
55
|
+
/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g,
|
|
56
|
+
(_, braced: string | undefined, plain: string | undefined) => {
|
|
57
|
+
const value = state.vars.get((braced ?? plain) as string);
|
|
58
|
+
if (value === undefined) unknown = true;
|
|
59
|
+
return value ?? "";
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
if (unknown || substituted.includes("$")) return undefined;
|
|
63
|
+
if (substituted === "~" || substituted.startsWith("~/"))
|
|
64
|
+
return `${homedir()}${substituted.slice(1)}`;
|
|
65
|
+
return substituted.startsWith("~") ? undefined : substituted;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The absolute path a word names, from the line's working directory; undefined when unknown. */
|
|
69
|
+
export function resolvePath(
|
|
70
|
+
word: string,
|
|
71
|
+
state: LineState,
|
|
72
|
+
): string | undefined {
|
|
73
|
+
const path = expand(word, state);
|
|
74
|
+
if (path === undefined) return undefined;
|
|
75
|
+
if (isAbsolute(path)) return resolve(path);
|
|
76
|
+
return state.cwd === undefined ? undefined : resolve(state.cwd, path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Records a simple command's effect on the line: an assignment, an `export`, or a `cd`. */
|
|
80
|
+
export function follow(argv: readonly string[], state: LineState): void {
|
|
81
|
+
const [head, ...args] = argv;
|
|
82
|
+
if (head === undefined) return;
|
|
83
|
+
const assignments =
|
|
84
|
+
head === "export"
|
|
85
|
+
? args
|
|
86
|
+
: argv.every((w) => ASSIGNMENT.test(w))
|
|
87
|
+
? argv
|
|
88
|
+
: [];
|
|
89
|
+
for (const word of assignments) {
|
|
90
|
+
const match = ASSIGNMENT.exec(word);
|
|
91
|
+
if (match)
|
|
92
|
+
state.vars.set(match[1] as string, expand(match[2] as string, state));
|
|
93
|
+
}
|
|
94
|
+
if (head === "cd" || head === "pushd" || head === "popd") {
|
|
95
|
+
const target = args.find((a) => !a.startsWith("-"));
|
|
96
|
+
state.cwd =
|
|
97
|
+
state.followsCd && head === "cd" && target !== undefined
|
|
98
|
+
? resolvePath(target, state)
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether `path` is `root` or under it, comparing the paths as written. */
|
|
104
|
+
export function under(path: string, root: string): boolean {
|
|
105
|
+
const rel = relative(resolve(root), resolve(path));
|
|
106
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The real path of `path`: its nearest existing ancestor's real path, joined with the rest. */
|
|
110
|
+
function realPath(path: string): string {
|
|
111
|
+
let existing = path;
|
|
112
|
+
while (!existsSync(existing) && dirname(existing) !== existing)
|
|
113
|
+
existing = dirname(existing);
|
|
114
|
+
try {
|
|
115
|
+
return join(realpathSync(existing), relative(existing, path));
|
|
116
|
+
} catch {
|
|
117
|
+
return path;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether a write to `path` stays inside a root, as written. */
|
|
122
|
+
export function insideRoots(path: string, state: LineState): boolean {
|
|
123
|
+
return state.roots.some((root) => under(path, root));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Why an `rm` is held, or undefined when every operand resolves, through symlinks, under a scratch
|
|
128
|
+
* root without being one; a glob is judged by its directory part.
|
|
129
|
+
*/
|
|
130
|
+
export function rmHeld(
|
|
131
|
+
args: readonly string[],
|
|
132
|
+
state: LineState,
|
|
133
|
+
): string | undefined {
|
|
134
|
+
const operands: string[] = [];
|
|
135
|
+
let options = true;
|
|
136
|
+
for (const arg of args) {
|
|
137
|
+
if (options && arg === "--") options = false;
|
|
138
|
+
else if (options && arg.startsWith("-") && arg !== "-") continue;
|
|
139
|
+
else operands.push(arg);
|
|
140
|
+
}
|
|
141
|
+
if (operands.length === 0) return "rm";
|
|
142
|
+
const roots = state.roots.map(realPath);
|
|
143
|
+
for (const operand of operands) {
|
|
144
|
+
const glob = /[*?[]/.exec(operand);
|
|
145
|
+
const pattern = glob ? operand : undefined;
|
|
146
|
+
const word = glob
|
|
147
|
+
? operand.slice(0, operand.lastIndexOf("/", glob.index) + 1) || "."
|
|
148
|
+
: operand;
|
|
149
|
+
// `.*` once matched `..`; a glob of hidden names is held.
|
|
150
|
+
if (pattern && basename(pattern).startsWith(".")) return "rm";
|
|
151
|
+
const path = resolvePath(word, state);
|
|
152
|
+
if (path === undefined || path === "/") return "rm";
|
|
153
|
+
const real = realPath(path);
|
|
154
|
+
const inside = roots.some((root) =>
|
|
155
|
+
pattern ? under(real, root) : under(real, root) && real !== root,
|
|
156
|
+
);
|
|
157
|
+
if (!inside) return "rm";
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type { HoldRule } from "../../holds.ts";
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { HoldContext, HoldRule } from "../../holds.ts";
|
|
4
3
|
import { assistantName } from "../../i18n/index.ts";
|
|
4
|
+
import { ownPush, type PushPolicy } from "./git-push.ts";
|
|
5
|
+
import {
|
|
6
|
+
follow,
|
|
7
|
+
insideRoots,
|
|
8
|
+
type LineState,
|
|
9
|
+
lineState,
|
|
10
|
+
resolvePath,
|
|
11
|
+
rmHeld,
|
|
12
|
+
} from "./shell-paths.ts";
|
|
13
|
+
|
|
14
|
+
export type { PushPolicy } from "./git-push.ts";
|
|
5
15
|
|
|
6
16
|
const MAX_SHOWN_COMMAND = 600;
|
|
7
17
|
|
|
@@ -18,7 +28,6 @@ const ALWAYS_HELD = new Set([
|
|
|
18
28
|
"sudo",
|
|
19
29
|
"su",
|
|
20
30
|
"doas",
|
|
21
|
-
"rm",
|
|
22
31
|
"dd",
|
|
23
32
|
"shutdown",
|
|
24
33
|
"reboot",
|
|
@@ -76,15 +85,17 @@ const WRAPPERS = new Set([
|
|
|
76
85
|
"xargs",
|
|
77
86
|
]);
|
|
78
87
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Whether a write to the word stays inside a scratch root. A word the line cannot resolve is read
|
|
90
|
+
* as written from the workspace, as before variables were followed.
|
|
91
|
+
*/
|
|
92
|
+
function writable(word: string, state: LineState): boolean {
|
|
93
|
+
if (word === "/dev/null") return true;
|
|
94
|
+
const path =
|
|
95
|
+
resolvePath(word, state) ??
|
|
96
|
+
resolvePath(word, { ...state, vars: new Map(), cwd: state.workspace }) ??
|
|
97
|
+
word;
|
|
98
|
+
return insideRoots(path, state);
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
/** Splits a command line into simple commands of words; quotes are removed, operators separate. */
|
|
@@ -123,8 +134,11 @@ export function simpleCommands(command: string): string[][] {
|
|
|
123
134
|
if (ch === "\n") endCommand();
|
|
124
135
|
else endWord();
|
|
125
136
|
} else if (";&|()`{}".includes(ch)) {
|
|
137
|
+
// A word cut by a command substitution keeps its mark, so its value counts as unknown.
|
|
138
|
+
if (ch === "`" && inWord) word += ch;
|
|
126
139
|
endCommand();
|
|
127
140
|
} else if (ch === "$" && command[i + 1] === "(") {
|
|
141
|
+
if (inWord) word += "$(";
|
|
128
142
|
endCommand();
|
|
129
143
|
i++;
|
|
130
144
|
} else if (ch === ">" || ch === "<") {
|
|
@@ -147,17 +161,22 @@ export function simpleCommands(command: string): string[][] {
|
|
|
147
161
|
return commands;
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Drops leading variable assignments, wrappers, and their options, leaving the real program;
|
|
166
|
+
* `prefix` collects what was dropped.
|
|
167
|
+
*/
|
|
168
|
+
function program(words: string[], prefix: string[] = []): string[] {
|
|
152
169
|
let rest = words;
|
|
153
170
|
for (;;) {
|
|
154
171
|
const [first] = rest;
|
|
155
172
|
if (first === undefined) return rest;
|
|
156
173
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) {
|
|
174
|
+
prefix.push(first);
|
|
157
175
|
rest = rest.slice(1);
|
|
158
176
|
continue;
|
|
159
177
|
}
|
|
160
178
|
if (WRAPPERS.has(basename(first))) {
|
|
179
|
+
prefix.push(basename(first));
|
|
161
180
|
rest = rest.slice(1);
|
|
162
181
|
// Wrapper options and the timeout's duration.
|
|
163
182
|
while (
|
|
@@ -173,33 +192,37 @@ function program(words: string[]): string[] {
|
|
|
173
192
|
}
|
|
174
193
|
}
|
|
175
194
|
|
|
176
|
-
/** Why one simple command is held, or undefined. */
|
|
177
|
-
function heldReason(
|
|
195
|
+
/** Why one simple command is held, or undefined; reads the line's state as it is before it. */
|
|
196
|
+
function heldReason(
|
|
197
|
+
words: string[],
|
|
198
|
+
state: LineState,
|
|
199
|
+
policy: PushPolicy,
|
|
200
|
+
): string | undefined {
|
|
178
201
|
for (let i = 0; i < words.length; i++) {
|
|
179
202
|
const op = words[i];
|
|
180
203
|
if (op === ">" || op === ">>") {
|
|
181
204
|
const target = words[i + 1];
|
|
182
|
-
if (
|
|
183
|
-
target &&
|
|
184
|
-
target !== "/dev/null" &&
|
|
185
|
-
!insideWorkspace(target, workspace)
|
|
186
|
-
)
|
|
187
|
-
return `writes to ${target}`;
|
|
205
|
+
if (target && !writable(target, state)) return `writes to ${target}`;
|
|
188
206
|
}
|
|
189
207
|
}
|
|
208
|
+
const prefix: string[] = [];
|
|
190
209
|
const argv = program(
|
|
191
210
|
words.filter(
|
|
192
211
|
(w, i) =>
|
|
193
212
|
!["<", ">", ">>", "dup"].includes(w) &&
|
|
194
213
|
!["<", ">", ">>"].includes(words[i - 1] ?? ""),
|
|
195
214
|
),
|
|
215
|
+
prefix,
|
|
196
216
|
);
|
|
197
217
|
const [head, ...args] = argv;
|
|
198
218
|
if (!head) return undefined;
|
|
199
219
|
const name = basename(head);
|
|
200
220
|
if (ALWAYS_HELD.has(name) || /^mkfs(\..+)?$/.test(name)) return name;
|
|
221
|
+
if (name === "rm")
|
|
222
|
+
// xargs adds operands nobody can see here.
|
|
223
|
+
return prefix.includes("xargs") ? "rm" : rmHeld(args, state);
|
|
201
224
|
if ((name === "bash" || name === "sh" || name === "zsh") && args[0] === "-c")
|
|
202
|
-
return commandHeld(args[1] ?? "",
|
|
225
|
+
return commandHeld(args[1] ?? "", state, policy);
|
|
203
226
|
const verbs = args.filter((a) => !a.startsWith("-"));
|
|
204
227
|
switch (name) {
|
|
205
228
|
case "systemctl":
|
|
@@ -255,11 +278,14 @@ function heldReason(words: string[], workspace: string): string | undefined {
|
|
|
255
278
|
? "installs or removes packages"
|
|
256
279
|
: undefined;
|
|
257
280
|
case "git": {
|
|
258
|
-
const [verb, ...rest] = gitCommand(args);
|
|
259
|
-
if (verb === "push")
|
|
281
|
+
const [globals, [verb, ...rest]] = gitCommand(args);
|
|
282
|
+
if (verb === "push") {
|
|
283
|
+
const plain = !prefix.some((w) => w.startsWith("GIT_"));
|
|
284
|
+
if (plain && ownPush(globals, rest, state, policy)) return undefined;
|
|
260
285
|
return rest.some((a) => a === "-f" || a.startsWith("--force"))
|
|
261
286
|
? "force-pushes"
|
|
262
287
|
: `pushes to GitHub, which deploys ${assistantName()} when the branch is main`;
|
|
288
|
+
}
|
|
263
289
|
if (verb === "reset" && rest.includes("--hard"))
|
|
264
290
|
return "git reset --hard";
|
|
265
291
|
return undefined;
|
|
@@ -270,11 +296,9 @@ function heldReason(words: string[], workspace: string): string | undefined {
|
|
|
270
296
|
return args.includes("-r") ? "removes a crontab" : undefined;
|
|
271
297
|
case "tee": {
|
|
272
298
|
const target = args.find(
|
|
273
|
-
(a) => !a.startsWith("-") && !
|
|
299
|
+
(a) => !a.startsWith("-") && !writable(a, state),
|
|
274
300
|
);
|
|
275
|
-
return target
|
|
276
|
-
? `writes to ${target}`
|
|
277
|
-
: undefined;
|
|
301
|
+
return target ? `writes to ${target}` : undefined;
|
|
278
302
|
}
|
|
279
303
|
default:
|
|
280
304
|
return undefined;
|
|
@@ -290,12 +314,12 @@ const GIT_VALUE_OPTIONS = new Set([
|
|
|
290
314
|
"--namespace",
|
|
291
315
|
]);
|
|
292
316
|
|
|
293
|
-
/**
|
|
294
|
-
function gitCommand(args: string[]): string[] {
|
|
317
|
+
/** Git's global options, such as `-C <dir>`, and the subcommand with its arguments. */
|
|
318
|
+
function gitCommand(args: string[]): [string[], string[]] {
|
|
295
319
|
let i = 0;
|
|
296
320
|
while (i < args.length && (args[i] as string).startsWith("-"))
|
|
297
321
|
i += GIT_VALUE_OPTIONS.has(args[i] as string) ? 2 : 1;
|
|
298
|
-
return args.slice(i);
|
|
322
|
+
return [args.slice(0, i), args.slice(i)];
|
|
299
323
|
}
|
|
300
324
|
|
|
301
325
|
const GH_BODY_FLAGS = ["-f", "-F", "--field", "--raw-field", "--input"];
|
|
@@ -348,44 +372,83 @@ function ghHeld(args: string[]): string | undefined {
|
|
|
348
372
|
}
|
|
349
373
|
}
|
|
350
374
|
|
|
351
|
-
|
|
375
|
+
/** Why a command line is held; a `bash -c` inside one starts from the outer line's state. */
|
|
376
|
+
function commandHeld(
|
|
377
|
+
command: string,
|
|
378
|
+
outer: LineState,
|
|
379
|
+
policy: PushPolicy,
|
|
380
|
+
): string | undefined {
|
|
381
|
+
const state: LineState = {
|
|
382
|
+
...lineState(command, outer.workspace, undefined),
|
|
383
|
+
roots: outer.roots,
|
|
384
|
+
vars: new Map(outer.vars),
|
|
385
|
+
cwd: outer.cwd,
|
|
386
|
+
};
|
|
352
387
|
for (const words of simpleCommands(command)) {
|
|
353
|
-
const reason = heldReason(words,
|
|
388
|
+
const reason = heldReason(words, state, policy);
|
|
354
389
|
if (reason) return reason;
|
|
390
|
+
follow(program(words), state);
|
|
391
|
+
// `export` and `cd` are kept by program(); bare assignments are dropped by it.
|
|
392
|
+
if (words.every((w) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)))
|
|
393
|
+
follow(words, state);
|
|
355
394
|
}
|
|
356
395
|
return undefined;
|
|
357
396
|
}
|
|
358
397
|
|
|
359
398
|
/**
|
|
360
|
-
* Pi's shell and file tools: a bash command that is destructive or reaches outside the
|
|
361
|
-
* workspace, and a write or edit outside
|
|
399
|
+
* Pi's shell and file tools: a bash command that is destructive or reaches outside the scratch
|
|
400
|
+
* roots (the shared workspace and the scratch dir), and a write or edit outside them, wait for the
|
|
401
|
+
* owner. Reading always runs.
|
|
362
402
|
*/
|
|
363
403
|
export function shellActionNeedingConfirmation(
|
|
364
404
|
toolName: string,
|
|
365
405
|
input: Record<string, unknown>,
|
|
366
|
-
workspace: string,
|
|
406
|
+
context: HoldContext & { workspace: string },
|
|
407
|
+
policy: PushPolicy = {},
|
|
367
408
|
): string | undefined {
|
|
409
|
+
const state = lineState(
|
|
410
|
+
typeof input.command === "string" ? input.command : "",
|
|
411
|
+
context.workspace,
|
|
412
|
+
context.scratchDir,
|
|
413
|
+
);
|
|
368
414
|
if (toolName === "bash") {
|
|
369
415
|
const command = typeof input.command === "string" ? input.command : "";
|
|
370
|
-
const reason = commandHeld(command,
|
|
416
|
+
const reason = commandHeld(command, state, policy);
|
|
371
417
|
return reason
|
|
372
418
|
? `run the shell command \`${command.slice(0, MAX_SHOWN_COMMAND)}\` on the host (${reason})`
|
|
373
419
|
: undefined;
|
|
374
420
|
}
|
|
375
421
|
if (toolName === "write" || toolName === "edit") {
|
|
376
422
|
const path = typeof input.path === "string" ? input.path : "";
|
|
377
|
-
return
|
|
423
|
+
return writable(path.replace(/^@/, ""), state)
|
|
378
424
|
? undefined
|
|
379
425
|
: `${toolName} the file ${path} on the host`;
|
|
380
426
|
}
|
|
381
427
|
return undefined;
|
|
382
428
|
}
|
|
383
429
|
|
|
384
|
-
/**
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
430
|
+
/**
|
|
431
|
+
* The shell rule with a push policy: a plain push to a repository of `ownPushOwners`, other than
|
|
432
|
+
* `heldPushRepos`, runs without a hold. Shell calls of a session without a workspace have no shell.
|
|
433
|
+
*/
|
|
434
|
+
export function shellHoldRuleFor(policy: PushPolicy = {}): Readonly<HoldRule> {
|
|
435
|
+
const frozen: PushPolicy = {
|
|
436
|
+
ownPushOwners: [...(policy.ownPushOwners ?? [])],
|
|
437
|
+
heldPushRepos: [...(policy.heldPushRepos ?? [])],
|
|
438
|
+
};
|
|
439
|
+
return Object.freeze<HoldRule>({
|
|
440
|
+
name: "shell",
|
|
441
|
+
describe: (tool, input, context) =>
|
|
442
|
+
context.workspace === undefined
|
|
443
|
+
? undefined
|
|
444
|
+
: shellActionNeedingConfirmation(
|
|
445
|
+
tool,
|
|
446
|
+
input,
|
|
447
|
+
{ ...context, workspace: context.workspace },
|
|
448
|
+
frozen,
|
|
449
|
+
),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The strict shell rule: every push is held. */
|
|
454
|
+
export const shellHoldRule: Readonly<HoldRule> = shellHoldRuleFor();
|