privateer-agent 0.6.9 → 0.7.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 +20 -20
- package/SECURITY.md +1 -1
- package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
- package/bin/privateer-launch.mjs +7 -6
- package/bin/privateer-subagent.mjs +1 -1
- package/extensions/privateer-brand.ts +1 -1
- package/extensions/privateer-connect.ts +14 -3
- package/extensions/privateer-tools.ts +1 -1
- package/package.json +1 -1
- package/src/auth/privateer.ts +2 -2
- package/src/channels/run.ts +5 -5
- package/src/channels/status.ts +7 -7
- package/src/cli/chat.ts +29 -3
- package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
- package/src/config/hosted.ts +5 -5
- package/src/crypto/accountTrust.ts +2 -2
- package/src/crypto/accountVerify.ts +1 -1
- package/src/{daemon → harbor}/index.ts +37 -37
- package/src/{daemon → harbor}/ipc.ts +19 -19
- package/src/{daemon → harbor}/service.ts +61 -28
- package/src/main.ts +1 -1
- package/src/providers/account.ts +1 -1
- package/src/providers/defaultModel.ts +1 -1
- package/src/remote/channelsControl.ts +8 -8
- package/src/remote/controlAuth.ts +1 -1
- package/src/remote/liveTaskSession.ts +4 -4
- package/src/remote/mcpControl.ts +2 -2
- package/src/remote/relayClient.ts +41 -21
- package/src/remote/remoteBridge.ts +17 -7
- package/src/remote/routinesControl.ts +7 -7
- package/src/remote/workflowsControl.ts +6 -6
- package/src/routines/delivery.ts +6 -6
- package/src/routines/schema.ts +3 -3
- package/src/routines/store.ts +3 -3
- package/src/routines/trigger.ts +1 -1
- package/src/tools/routine.ts +8 -8
- package/src/util/fileMentions.ts +232 -0
- package/src/workflows/expr.ts +1 -1
- package/src/workflows/runner.ts +3 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +1 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @file mentions — let a prompt reference files on the terminal's machine by typing
|
|
2
|
+
// `@path`. Used by BOTH surfaces:
|
|
3
|
+
// • the local REPL (readline tab-completion + resolution at submit)
|
|
4
|
+
// • the app composer, driven over the relay (a files_search palette; the SAME
|
|
5
|
+
// resolution runs on the terminal when the prompt lands)
|
|
6
|
+
//
|
|
7
|
+
// The mention token stays INLINE in the prompt (so the model sees the reference in
|
|
8
|
+
// context) and each referenced file's content is appended after it as a
|
|
9
|
+
// <file name="…">…</file> block — text inline, images as real attachments. This
|
|
10
|
+
// mirrors Pi's own @file CLI-arg expander (cli/file-processor) but is a library, not
|
|
11
|
+
// a process: it never exits on a bad path, and it is CWD-CONSTRAINED.
|
|
12
|
+
//
|
|
13
|
+
// SECURITY: resolution is a client-side text expansion that bypasses the permission
|
|
14
|
+
// gate (unlike the Read tool). A remote driver is the account owner, but a
|
|
15
|
+
// gate-bypassing arbitrary read (`@/etc/shadow`, `@../secrets`) is exactly what we
|
|
16
|
+
// must not grant. So every token MUST resolve inside cwd — anything that escapes the
|
|
17
|
+
// cwd subtree (absolute paths, `..`, symlink targets outside) is skipped, not read.
|
|
18
|
+
// The same rule bounds the relay file-search so filenames outside the project never
|
|
19
|
+
// leak to the controller.
|
|
20
|
+
|
|
21
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
22
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
23
|
+
|
|
24
|
+
/** An image attachment, shaped for AgentSession.prompt()'s `images` option (Pi's ImageContent). */
|
|
25
|
+
export interface MentionImage {
|
|
26
|
+
type: "image";
|
|
27
|
+
data: string; // base64
|
|
28
|
+
mimeType: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResolvedMentions {
|
|
32
|
+
/** The prompt with each referenced file's content appended as a <file> block. */
|
|
33
|
+
text: string;
|
|
34
|
+
/** Image attachments to pass via prompt options.images. */
|
|
35
|
+
images: MentionImage[];
|
|
36
|
+
/** cwd-relative paths that were successfully attached. */
|
|
37
|
+
resolved: string[];
|
|
38
|
+
/** Raw tokens that couldn't be attached (missing / outside cwd / a dir / too big). */
|
|
39
|
+
skipped: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Inline text stays reasonable; a giant file would blow the context and the relay.
|
|
43
|
+
const MAX_TEXT_BYTES = 256 * 1024; // 256 KB per text file inlined
|
|
44
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image before base64
|
|
45
|
+
|
|
46
|
+
const IMAGE_EXT: Record<string, string> = {
|
|
47
|
+
png: "image/png",
|
|
48
|
+
jpg: "image/jpeg",
|
|
49
|
+
jpeg: "image/jpeg",
|
|
50
|
+
gif: "image/gif",
|
|
51
|
+
webp: "image/webp",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const extOf = (name: string): string => {
|
|
55
|
+
const dot = name.lastIndexOf(".");
|
|
56
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Trailing characters that are almost always sentence punctuation, not part of a
|
|
60
|
+
// filename — trimmed from a token if the trimmed form resolves and the raw doesn't.
|
|
61
|
+
const TRAIL_PUNCT = /[.,;:!?)\]}>]+$/;
|
|
62
|
+
|
|
63
|
+
// A mention is `@` at start-of-string or after whitespace, then either a "quoted path"
|
|
64
|
+
// (allows spaces) or a run of non-whitespace path characters. Capturing group 2 is the
|
|
65
|
+
// path (quoted contents via group 3, else the bare run).
|
|
66
|
+
const MENTION_RE = /(^|\s)@("([^"]+)"|[^\s@]+)/g;
|
|
67
|
+
|
|
68
|
+
/** Pull the raw path tokens out of a prompt (order-preserving, de-duplicated). */
|
|
69
|
+
export function parseMentions(text: string): string[] {
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
for (const m of text.matchAll(MENTION_RE)) {
|
|
73
|
+
const raw = m[3] ?? m[2]; // quoted contents, else the bare run
|
|
74
|
+
if (raw && !seen.has(raw)) {
|
|
75
|
+
seen.add(raw);
|
|
76
|
+
out.push(raw);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Resolve a raw token to an absolute path inside cwd, or null if it escapes / doesn't
|
|
83
|
+
// exist. Follows the real (symlink-resolved) path and re-checks containment so a
|
|
84
|
+
// symlink inside cwd pointing outside can't be used to read out.
|
|
85
|
+
async function resolveInsideCwd(raw: string, cwd: string): Promise<string | null> {
|
|
86
|
+
// A relative path only — an absolute token is an escape attempt by definition.
|
|
87
|
+
if (isAbsolute(raw)) return null;
|
|
88
|
+
const abs = resolve(cwd, raw);
|
|
89
|
+
const within = (p: string): boolean => p === cwd || p.startsWith(cwd + sep);
|
|
90
|
+
if (!within(abs)) return null; // `..` climbed out
|
|
91
|
+
try {
|
|
92
|
+
const real = await realpath(abs);
|
|
93
|
+
// realpath the cwd too, so a symlinked project root still matches.
|
|
94
|
+
const realCwd = await realpath(cwd).catch(() => cwd);
|
|
95
|
+
if (real !== realCwd && !real.startsWith(realCwd + sep)) return null;
|
|
96
|
+
return real;
|
|
97
|
+
} catch {
|
|
98
|
+
return null; // doesn't exist
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Try the token as-is, then progressively trimmed of trailing punctuation, returning
|
|
103
|
+
// the first form that resolves to a readable file inside cwd.
|
|
104
|
+
async function resolveToken(raw: string, cwd: string): Promise<string | null> {
|
|
105
|
+
const candidates = [raw];
|
|
106
|
+
const trimmed = raw.replace(TRAIL_PUNCT, "");
|
|
107
|
+
if (trimmed && trimmed !== raw) candidates.push(trimmed);
|
|
108
|
+
for (const c of candidates) {
|
|
109
|
+
const abs = await resolveInsideCwd(c, cwd);
|
|
110
|
+
if (abs) return abs;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Expand every `@path` mention in `text` into appended <file> blocks (text) plus image
|
|
117
|
+
* attachments. Unresolved mentions are left inline verbatim and reported in `skipped`.
|
|
118
|
+
* The returned `text` equals the input when there are no resolvable mentions.
|
|
119
|
+
*/
|
|
120
|
+
export async function resolveMentions(text: string, cwd: string): Promise<ResolvedMentions> {
|
|
121
|
+
const tokens = parseMentions(text);
|
|
122
|
+
const images: MentionImage[] = [];
|
|
123
|
+
const resolved: string[] = [];
|
|
124
|
+
const skipped: string[] = [];
|
|
125
|
+
const blocks: string[] = [];
|
|
126
|
+
// resolveToken returns the symlink-resolved (real) absolute path, so relative paths
|
|
127
|
+
// must be computed against the real cwd — otherwise a symlinked cwd (e.g. /var →
|
|
128
|
+
// /private/var on macOS) yields a spurious `../../…` prefix.
|
|
129
|
+
const realCwd = await realpath(cwd).catch(() => cwd);
|
|
130
|
+
|
|
131
|
+
for (const raw of tokens) {
|
|
132
|
+
const abs = await resolveToken(raw, cwd);
|
|
133
|
+
if (!abs) { skipped.push(raw); continue; }
|
|
134
|
+
let st;
|
|
135
|
+
try { st = await stat(abs); } catch { skipped.push(raw); continue; }
|
|
136
|
+
if (!st.isFile() || st.size === 0) { skipped.push(raw); continue; }
|
|
137
|
+
const rel = relative(realCwd, abs) || basename(abs);
|
|
138
|
+
const mime = IMAGE_EXT[extOf(abs)];
|
|
139
|
+
try {
|
|
140
|
+
if (mime) {
|
|
141
|
+
if (st.size > MAX_IMAGE_BYTES) { skipped.push(raw); continue; }
|
|
142
|
+
const buf = await readFile(abs);
|
|
143
|
+
images.push({ type: "image", data: buf.toString("base64"), mimeType: mime });
|
|
144
|
+
// A bare reference so the model ties the image to the path it saw inline.
|
|
145
|
+
blocks.push(`<file name="${rel}"></file>`);
|
|
146
|
+
} else {
|
|
147
|
+
if (st.size > MAX_TEXT_BYTES) { skipped.push(raw); continue; }
|
|
148
|
+
const content = await readFile(abs, "utf-8");
|
|
149
|
+
blocks.push(`<file name="${rel}">\n${content}\n</file>`);
|
|
150
|
+
}
|
|
151
|
+
resolved.push(rel);
|
|
152
|
+
} catch {
|
|
153
|
+
skipped.push(raw);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const out = blocks.length ? `${text}\n\n${blocks.join("\n")}` : text;
|
|
158
|
+
return { text: out, images, resolved, skipped };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── autocomplete ──────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export interface FileMatch {
|
|
164
|
+
/** cwd-relative path (directories carry a trailing "/"). */
|
|
165
|
+
path: string;
|
|
166
|
+
isDir: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Directory entries we never surface as suggestions (noise / not project files).
|
|
170
|
+
const IGNORE_DIRS = new Set([".git", "node_modules", ".DS_Store"]);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* List up to `limit` files/dirs inside cwd whose path matches `query` — the text the
|
|
174
|
+
* user typed after `@`. A query with a trailing "/" (or ending at a real dir) lists
|
|
175
|
+
* that directory's contents; otherwise it prefix-matches the basename within the
|
|
176
|
+
* query's parent dir. Case-insensitive. CWD-constrained: a query that escapes cwd
|
|
177
|
+
* returns nothing.
|
|
178
|
+
*/
|
|
179
|
+
export async function searchFiles(query: string, cwd: string, limit = 50): Promise<FileMatch[]> {
|
|
180
|
+
const q = query ?? "";
|
|
181
|
+
if (isAbsolute(q)) return [];
|
|
182
|
+
// Split into the directory to scan and the basename prefix to filter by. A trailing
|
|
183
|
+
// slash means "list this dir", so the prefix is empty.
|
|
184
|
+
const endsWithSlash = q.endsWith("/");
|
|
185
|
+
const dirPart = endsWithSlash ? q : dirname(q);
|
|
186
|
+
const prefix = endsWithSlash ? "" : basename(q);
|
|
187
|
+
const scanRel = dirPart === "." ? "" : dirPart;
|
|
188
|
+
const scanAbs = resolve(cwd, scanRel);
|
|
189
|
+
// Containment check (mirror resolveInsideCwd, sync form — no realpath needed for a listing).
|
|
190
|
+
if (scanAbs !== cwd && !scanAbs.startsWith(cwd + sep)) return [];
|
|
191
|
+
|
|
192
|
+
let entries: import("node:fs").Dirent[];
|
|
193
|
+
try {
|
|
194
|
+
entries = await readdir(scanAbs, { withFileTypes: true });
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
const pfx = prefix.toLowerCase();
|
|
199
|
+
const matches: FileMatch[] = [];
|
|
200
|
+
for (const e of entries) {
|
|
201
|
+
if (IGNORE_DIRS.has(e.name)) continue;
|
|
202
|
+
if (pfx && !e.name.toLowerCase().startsWith(pfx)) continue;
|
|
203
|
+
if (e.name.startsWith(".") && !pfx.startsWith(".")) continue; // hide dotfiles unless asked
|
|
204
|
+
const isDir = e.isDirectory();
|
|
205
|
+
const rel = scanRel ? join(scanRel, e.name) : e.name;
|
|
206
|
+
matches.push({ path: isDir ? `${rel}/` : rel, isDir });
|
|
207
|
+
if (matches.length >= limit) break;
|
|
208
|
+
}
|
|
209
|
+
// Directories first, then alphabetical — the natural drill-down order.
|
|
210
|
+
matches.sort((a, b) => (a.isDir === b.isDir ? a.path.localeCompare(b.path) : a.isDir ? -1 : 1));
|
|
211
|
+
return matches;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A Node readline completer for `@`-mentions. Given the line up to the cursor, if it
|
|
216
|
+
* ends in an `@token`, returns full-line completions (readline replaces the whole
|
|
217
|
+
* line) so the mention drills into the cwd tree on Tab. Returns [[], line] when the
|
|
218
|
+
* cursor isn't in a mention, leaving other completion untouched.
|
|
219
|
+
*/
|
|
220
|
+
export async function completeMention(line: string, cwd: string): Promise<[string[], string]> {
|
|
221
|
+
// Find the last unquoted-ish `@token` that runs to the end of the line.
|
|
222
|
+
const m = /(^|\s)@([^\s@]*)$/.exec(line);
|
|
223
|
+
if (!m) return [[], line];
|
|
224
|
+
const token = m[2];
|
|
225
|
+
const tokenStart = m.index + m[1].length; // index of the '@'
|
|
226
|
+
const head = line.slice(0, tokenStart); // everything before '@'
|
|
227
|
+
const matches = await searchFiles(token, cwd, 100);
|
|
228
|
+
// Rebuild each as a full line: head + "@" + path. A single dir match keeps the
|
|
229
|
+
// trailing slash so the next Tab drills in.
|
|
230
|
+
const hits = matches.map((mm) => `${head}@${mm.path}`);
|
|
231
|
+
return [hits, line];
|
|
232
|
+
}
|
package/src/workflows/expr.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// The confined workflow expression/template engine now lives in the standalone
|
|
2
2
|
// `privateer-workflow` package (its canonical home). This module re-exports it so the
|
|
3
|
-
//
|
|
3
|
+
// harbor's existing `../workflows/expr.ts` import paths keep working unchanged.
|
|
4
4
|
export * from "privateer-workflow/expr";
|
package/src/workflows/runner.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The workflow runner now lives in the standalone `privateer-workflow` package (its
|
|
2
|
-
// canonical home). This module re-exports it so the
|
|
2
|
+
// canonical home). This module re-exports it so the harbor's existing
|
|
3
3
|
// `../workflows/runner.ts` import paths keep working unchanged.
|
|
4
4
|
//
|
|
5
|
-
// The
|
|
6
|
-
// runSession, relay approvals, gated child processes, the cloud outbox) in
|
|
5
|
+
// The harbor wires the runner's injected RunnerDeps to its own capabilities (headless
|
|
6
|
+
// runSession, relay approvals, gated child processes, the cloud outbox) in harbor/index.ts
|
|
7
7
|
// — that seam is unchanged; only the engine's source moved out to the shared package.
|
|
8
8
|
export * from "privateer-workflow/runner";
|
package/src/workflows/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The declarative workflow schema now lives in the standalone `privateer-workflow` package
|
|
2
|
-
// (its canonical home). This module re-exports it so the
|
|
2
|
+
// (its canonical home). This module re-exports it so the harbor's existing
|
|
3
3
|
// `../workflows/schema.ts` import paths (schema.ts is also the store's dependency) keep
|
|
4
4
|
// working unchanged.
|
|
5
5
|
export * from "privateer-workflow/schema";
|
package/src/workflows/store.ts
CHANGED
|
@@ -52,7 +52,7 @@ function parseWorkflow(raw: string): Workflow | null {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// All valid workflows on disk, sorted by name. Corrupt/invalid files are skipped, not
|
|
55
|
-
// thrown — a hand-mangled file shouldn't take down the
|
|
55
|
+
// thrown — a hand-mangled file shouldn't take down the harbor or the app's list.
|
|
56
56
|
export function loadWorkflows(): Workflow[] {
|
|
57
57
|
const dir = workflowsDir();
|
|
58
58
|
if (!existsSync(dir)) return [];
|