pi-shorthand 0.5.0 → 0.6.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/README.md +4 -1
- package/display.ts +9 -2
- package/index.ts +8 -2
- package/linux-observer.c +89 -4
- package/overlay-linux.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,9 +20,10 @@ xcode-select --install
|
|
|
20
20
|
curl -fsSL https://agentfs.ai/install | bash
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
On Linux, install bubblewrap 0.
|
|
23
|
+
On Linux, install bubblewrap 0.11 or later, a C compiler, and `lsof`. For Debian and Ubuntu, check the available bubblewrap version before installing:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
+
apt-cache policy bubblewrap
|
|
26
27
|
sudo apt install bubblewrap build-essential lsof
|
|
27
28
|
```
|
|
28
29
|
|
|
@@ -31,3 +32,5 @@ sudo apt install bubblewrap build-essential lsof
|
|
|
31
32
|
pi-shorthand gives Pi a programming environment instead of a patch format. This makes multi-file and structural edits possible in one call, but does not guarantee that Pi will find it easier or more reliable than its built-in edit tool. Which works better depends on the model and the task.
|
|
32
33
|
|
|
33
34
|
Programs edit a private workspace, with host files outside the repository kept read-only. By default, a failure keeps completed files and rolls back failed or interrupted edits. Concurrent edits can cause a run to be rejected; conflict detection is best-effort, not an atomic commit.
|
|
35
|
+
|
|
36
|
+
The `code` tool uses Pi's working directory by default. Pass `cwd` to target another checkout; relative paths are resolved from Pi's working directory. For example, when Pi starts in a bare worktree container, `cwd: "child"` targets its `child` worktree. The chosen directory must be inside a Git worktree.
|
package/display.ts
CHANGED
|
@@ -21,8 +21,15 @@ const INLINE_DIFF_LINES = 40; // a longer diff collapses to a list of its files
|
|
|
21
21
|
const LISTED_FILES = 8; // …showing this many, then "and N more files"
|
|
22
22
|
const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
|
|
23
23
|
|
|
24
|
-
export function callLine(
|
|
25
|
-
|
|
24
|
+
export function callLine(
|
|
25
|
+
args: { title?: string; cwd?: string; timeout?: number; rollback?: string },
|
|
26
|
+
theme: Theme,
|
|
27
|
+
): string {
|
|
28
|
+
const settings = [
|
|
29
|
+
args.cwd && `cwd ${args.cwd}`,
|
|
30
|
+
args.rollback === "all" && "rollback all",
|
|
31
|
+
args.timeout && `program timeout ${args.timeout}s`,
|
|
32
|
+
];
|
|
26
33
|
const suffix = settings.filter(Boolean).join(", ");
|
|
27
34
|
return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
|
|
28
35
|
}
|
package/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ class RunnerError extends Error {
|
|
|
35
35
|
// Runs typically take well under a second. Longer transformations can request more time.
|
|
36
36
|
const DEFAULT_TIMEOUT_SECONDS = 2;
|
|
37
37
|
|
|
38
|
-
const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
|
|
38
|
+
const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. Set cwd to a checkout path when Pi's working directory is outside the repository, such as a child worktree in a bare worktree container. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
|
|
39
39
|
|
|
40
40
|
Common operations:
|
|
41
41
|
- edit({ path, oldText, newText }) replaces exactly one literal occurrence; missing or ambiguous text is an error. Use text edits for known source, structural matching when it saves enumerating occurrences or preserves varying syntax.
|
|
@@ -70,6 +70,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
70
|
parameters: Type.Object({
|
|
71
71
|
title: Type.String({ description: "A few words describing the change, shown to the user" }),
|
|
72
72
|
program: Type.String({ description: "TypeScript program run with Bun (top-level await allowed)" }),
|
|
73
|
+
cwd: Type.Optional(
|
|
74
|
+
Type.String({
|
|
75
|
+
description:
|
|
76
|
+
"Working directory for the program; relative to Pi's working directory, or an absolute path. Defaults to Pi's working directory. Must be inside a git worktree.",
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
73
79
|
rollback: Type.Optional(
|
|
74
80
|
StringEnum(["all", "file"] as const, {
|
|
75
81
|
description:
|
|
@@ -95,7 +101,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
101
|
try {
|
|
96
102
|
result = await runWithBun(
|
|
97
103
|
{
|
|
98
|
-
cwd: ctx.cwd,
|
|
104
|
+
cwd: path.resolve(ctx.cwd, params.cwd ?? "."),
|
|
99
105
|
program: params.program,
|
|
100
106
|
timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
101
107
|
rollback: params.rollback ?? "file",
|
package/linux-observer.c
CHANGED
|
@@ -37,6 +37,7 @@ static const char *repository;
|
|
|
37
37
|
static size_t repository_length;
|
|
38
38
|
static pid_t initial;
|
|
39
39
|
static volatile sig_atomic_t stopping;
|
|
40
|
+
static int unresolved_external_permission;
|
|
40
41
|
|
|
41
42
|
enum observation_kind {
|
|
42
43
|
OBSERVE_CONTENTS = 'C',
|
|
@@ -121,6 +122,61 @@ static void descriptor(pid_t pid, int fd, char result[PATH_MAX]) {
|
|
|
121
122
|
result[length] = 0;
|
|
122
123
|
if (within(result) && strstr(result, " (deleted)")) die("unresolved deleted repository descriptor");
|
|
123
124
|
}
|
|
125
|
+
/* A missing getprocattr hook reports EINVAL. Other errors, including a label
|
|
126
|
+
* larger than our buffer, leave the access context unknown. */
|
|
127
|
+
static int security_label(const char *path, char label[4096], size_t *length) {
|
|
128
|
+
int fd = open(path, O_RDONLY|O_CLOEXEC);
|
|
129
|
+
if (fd < 0) return -1;
|
|
130
|
+
size_t used = 0;
|
|
131
|
+
for (;;) {
|
|
132
|
+
char extra;
|
|
133
|
+
char *destination = used < 4096 ? label + used : &extra;
|
|
134
|
+
size_t available = used < 4096 ? 4096 - used : 1;
|
|
135
|
+
ssize_t count = read(fd, destination, available);
|
|
136
|
+
if (count < 0 && errno == EINTR) continue;
|
|
137
|
+
if (count < 0) {
|
|
138
|
+
int error = errno;
|
|
139
|
+
close(fd);
|
|
140
|
+
return used == 0 && error == EINVAL ? 0 : -1;
|
|
141
|
+
}
|
|
142
|
+
if (!count) { close(fd); *length = used; return 1; }
|
|
143
|
+
if (used == 4096) { close(fd); return -1; }
|
|
144
|
+
used += count;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
static int same_access_context(pid_t pid) {
|
|
148
|
+
char tracee_path[96], tracee[4096], self[4096];
|
|
149
|
+
snprintf(tracee_path, sizeof(tracee_path), "/proc/%d/status", pid);
|
|
150
|
+
FILE *a = fopen(tracee_path, "r"), *b = fopen("/proc/self/status", "r");
|
|
151
|
+
if (!a || !b) { if (a) fclose(a); if (b) fclose(b); return 0; }
|
|
152
|
+
const char *fields[] = { "Uid:", "Gid:", "Groups:", "CapEff:" };
|
|
153
|
+
unsigned matched = 0;
|
|
154
|
+
while (fgets(tracee, sizeof(tracee), a)) {
|
|
155
|
+
for (size_t j = 0; j < sizeof(fields)/sizeof(*fields); j++) {
|
|
156
|
+
if (strncmp(tracee, fields[j], strlen(fields[j]))) continue;
|
|
157
|
+
if (!strchr(tracee, '\n')) { fclose(a); fclose(b); return 0; }
|
|
158
|
+
rewind(b);
|
|
159
|
+
int found = 0;
|
|
160
|
+
while (fgets(self, sizeof(self), b)) {
|
|
161
|
+
if (!strncmp(self, fields[j], strlen(fields[j]))) {
|
|
162
|
+
if (!strchr(self, '\n') || strcmp(tracee, self)) { fclose(a); fclose(b); return 0; }
|
|
163
|
+
found = 1;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
matched += found;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
fclose(a); fclose(b);
|
|
171
|
+
if (matched != sizeof(fields)/sizeof(*fields)) return 0;
|
|
172
|
+
snprintf(tracee_path, sizeof(tracee_path), "/proc/%d/attr/current", pid);
|
|
173
|
+
size_t tracee_length = 0, self_length = 0;
|
|
174
|
+
int tracee_label = security_label(tracee_path, tracee, &tracee_length);
|
|
175
|
+
int self_label = security_label("/proc/self/attr/current", self, &self_length);
|
|
176
|
+
return (tracee_label == 0 && self_label == 0) ||
|
|
177
|
+
(tracee_label == 1 && self_label == 1 && tracee_length == self_length &&
|
|
178
|
+
!memcmp(tracee, self, tracee_length));
|
|
179
|
+
}
|
|
124
180
|
/* Walk symlinks in the tracee's mount view, recording each repository link.
|
|
125
181
|
* Never use realpath(/proc/PID/root/...): that can resolve against the host mount.
|
|
126
182
|
*/
|
|
@@ -182,7 +238,17 @@ static void resolve_name(pid_t pid, int fd, const char *input, int follow, char
|
|
|
182
238
|
snprintf(visible, sizeof(visible), "/proc/%d/root%s", pid, result);
|
|
183
239
|
struct stat info;
|
|
184
240
|
int exists = lstat(visible, &info);
|
|
185
|
-
if (exists < 0 && errno != ENOENT && errno != ENOTDIR)
|
|
241
|
+
if (exists < 0 && errno != ENOENT && errno != ENOTDIR) {
|
|
242
|
+
if ((errno == EACCES || errno == EPERM) && !within(result) && same_access_context(pid)) {
|
|
243
|
+
/* Matching filesystem credentials, capabilities and security
|
|
244
|
+
* label make this external denial apply to the tracee too.
|
|
245
|
+
* Confirm the syscall is denied before resuming it. */
|
|
246
|
+
unresolved_external_permission = 1;
|
|
247
|
+
result[0] = 0;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
die("cannot inspect tracee path");
|
|
251
|
+
}
|
|
186
252
|
if ((!*rest && !follow) || exists < 0 || !S_ISLNK(info.st_mode)) {
|
|
187
253
|
snprintf(pending, sizeof(pending), "%s", rest);
|
|
188
254
|
continue;
|
|
@@ -353,6 +419,7 @@ struct task {
|
|
|
353
419
|
int initial_stop;
|
|
354
420
|
int registered;
|
|
355
421
|
int waiting;
|
|
422
|
+
int checking_permission;
|
|
356
423
|
struct task *next;
|
|
357
424
|
};
|
|
358
425
|
static struct task *tasks;
|
|
@@ -372,7 +439,8 @@ static struct task *task(pid_t pid) {
|
|
|
372
439
|
* is not completion: retain the live record until wait reports its death or
|
|
373
440
|
* an exec event explicitly identifies the replaced thread ID. */
|
|
374
441
|
static void resume_task(pid_t pid, int signal) {
|
|
375
|
-
|
|
442
|
+
int request = task(pid)->checking_permission ? PTRACE_SYSCALL : PTRACE_CONT;
|
|
443
|
+
if (ptrace(request, pid, 0, signal) && errno != ESRCH) die("trace resume failed");
|
|
376
444
|
}
|
|
377
445
|
static void syscall_entry(pid_t pid, struct ptrace_syscall_info *info) {
|
|
378
446
|
__u64 *a = info->seccomp.args; long nr = info->seccomp.nr;
|
|
@@ -514,6 +582,8 @@ static void exec_event(pid_t pid) {
|
|
|
514
582
|
unsigned long former;
|
|
515
583
|
if (ptrace(PTRACE_GETEVENTMSG, pid, 0, &former)) die("missing exec thread identity");
|
|
516
584
|
struct task *current = task(pid);
|
|
585
|
+
if (current->checking_permission || (former != (unsigned long)pid && task(former)->checking_permission))
|
|
586
|
+
die("unresolved external path was executed");
|
|
517
587
|
if (former != (unsigned long)pid) {
|
|
518
588
|
current->bootstrap = task(former)->bootstrap;
|
|
519
589
|
task(former)->alive = 0;
|
|
@@ -559,7 +629,7 @@ int main(int argc, char **argv) {
|
|
|
559
629
|
int status, exit_code = 125;
|
|
560
630
|
if (waitpid(initial, &status, 0) != initial || !WIFSTOPPED(status)) die("initial trace stop");
|
|
561
631
|
long flags = PTRACE_O_TRACEFORK|PTRACE_O_TRACEVFORK|PTRACE_O_TRACECLONE|PTRACE_O_TRACEEXEC|
|
|
562
|
-
PTRACE_O_TRACESECCOMP|PTRACE_O_EXITKILL;
|
|
632
|
+
PTRACE_O_TRACESECCOMP|PTRACE_O_TRACESYSGOOD|PTRACE_O_EXITKILL;
|
|
563
633
|
if (ptrace(PTRACE_SETOPTIONS, initial, 0, flags)) die("ptrace options");
|
|
564
634
|
task(initial)->bootstrap = BEFORE_BWRAP_EXEC;
|
|
565
635
|
task(initial)->registered = 1;
|
|
@@ -593,7 +663,22 @@ int main(int argc, char **argv) {
|
|
|
593
663
|
struct ptrace_syscall_info info;
|
|
594
664
|
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0 || info.op != PTRACE_SYSCALL_INFO_SECCOMP)
|
|
595
665
|
die("syscall information unavailable");
|
|
596
|
-
|
|
666
|
+
unresolved_external_permission = 0;
|
|
667
|
+
syscall_entry(pid, &info);
|
|
668
|
+
if (unresolved_external_permission) task(pid)->checking_permission = 1;
|
|
669
|
+
signal = 0;
|
|
670
|
+
} else if (signal == (SIGTRAP|0x80) && task(pid)->checking_permission) {
|
|
671
|
+
struct ptrace_syscall_info info;
|
|
672
|
+
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0)
|
|
673
|
+
die("unresolved external path result unavailable");
|
|
674
|
+
if (info.op == PTRACE_SYSCALL_INFO_EXIT) {
|
|
675
|
+
if (info.exit.rval != -EACCES && info.exit.rval != -EPERM)
|
|
676
|
+
die("unresolved external path was accessed");
|
|
677
|
+
task(pid)->checking_permission = 0;
|
|
678
|
+
} else if (info.op != PTRACE_SYSCALL_INFO_ENTRY) {
|
|
679
|
+
die("unresolved external path result unavailable");
|
|
680
|
+
}
|
|
681
|
+
signal = 0;
|
|
597
682
|
} else if (event == PTRACE_EVENT_FORK || event == PTRACE_EVENT_VFORK || event == PTRACE_EVENT_CLONE) {
|
|
598
683
|
descendant_event(pid);
|
|
599
684
|
signal = 0;
|
package/overlay-linux.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Linux: bubblewrap (0.
|
|
2
|
+
* Linux: bubblewrap (0.11+) mounts a kernel overlayfs over the repository in a private mount
|
|
3
3
|
* namespace, so only the program sees it. Its writes land in an upper directory in tempDir, which
|
|
4
4
|
* is also where the changes are read from. There's nothing to undo afterwards.
|
|
5
5
|
*/
|
|
@@ -14,7 +14,7 @@ import type { TransactionJournal } from "./transaction-journal.ts";
|
|
|
14
14
|
|
|
15
15
|
export async function openLinuxOverlay(repo: string, tempDir: string): Promise<Overlay> {
|
|
16
16
|
const bwrap = Bun.which("bwrap");
|
|
17
|
-
if (!bwrap) throw new Error("The code tool needs bubblewrap (0.
|
|
17
|
+
if (!bwrap) throw new Error("The code tool needs bubblewrap (0.11 or later) on Linux.");
|
|
18
18
|
|
|
19
19
|
// Best-effort live lower: validation does not remove OverlayFS's documented
|
|
20
20
|
// restriction on concurrent external modifications of an underlying layer.
|