privateer-agent 0.6.10 → 0.8.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 +2 -2
- package/extensions/privateer-tools.ts +1 -1
- package/package.json +4 -2
- package/src/auth/privateer.ts +2 -2
- package/src/channels/run.ts +16 -7
- package/src/channels/status.ts +7 -7
- package/src/cli/chat.ts +39 -5
- 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 +81 -44
- package/src/harbor/ipc.ts +175 -0
- package/src/{daemon → harbor}/service.ts +61 -28
- package/src/main.ts +1 -1
- package/src/mcp/catalog.ts +106 -0
- package/src/providers/account.ts +58 -11
- package/src/providers/catalog.ts +8 -6
- package/src/providers/defaultModel.ts +1 -1
- package/src/providers/phala/aci-verifier/VENDORED.md +23 -0
- package/src/providers/phala/aci-verifier/crypto.ts +95 -0
- package/src/providers/phala/aci-verifier/digest.ts +116 -0
- package/src/providers/phala/aci-verifier/e2ee-channel.ts +242 -0
- package/src/providers/phala/aci-verifier/e2ee.ts +73 -0
- package/src/providers/phala/aci-verifier/errors.ts +41 -0
- package/src/providers/phala/aci-verifier/index.ts +87 -0
- package/src/providers/phala/aci-verifier/jcs.ts +69 -0
- package/src/providers/phala/aci-verifier/receipt.ts +139 -0
- package/src/providers/phala/aci-verifier/report.ts +126 -0
- package/src/providers/phala/aci-verifier/types.ts +139 -0
- package/src/providers/phala/sse.ts +43 -0
- package/src/providers/phala/webcrypto-globals.d.ts +17 -0
- package/src/providers/phalaSeal.ts +200 -0
- package/src/providers/sealedShim.ts +295 -0
- package/src/remote/channelsControl.ts +8 -8
- package/src/remote/controlAuth.ts +1 -1
- package/src/remote/liveTaskSession.ts +15 -7
- 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
- package/src/daemon/ipc.ts +0 -127
|
@@ -27,6 +27,7 @@ export interface RelayLike {
|
|
|
27
27
|
sendCommands(commands: { name: string; description?: string }[]): void;
|
|
28
28
|
requestSelect(id: string, req: SelectRequest): void;
|
|
29
29
|
requestInput(id: string, req: InputRequest): void;
|
|
30
|
+
sendFileMatches(id: string, matches: { path: string; isDir: boolean }[]): void;
|
|
30
31
|
sendExtensions(payload: ExtensionsPayload): void;
|
|
31
32
|
sendSkills(payload: SkillsPayload): void;
|
|
32
33
|
}
|
|
@@ -93,6 +94,9 @@ export interface RemoteBridgeConfig {
|
|
|
93
94
|
onSkillCreate?: (skill: { name: string; description: string; instructions: string }, sig?: string, ts?: number) => void;
|
|
94
95
|
onSkillDelete?: (name: string, sig?: string, ts?: number) => void;
|
|
95
96
|
onSkillSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
|
|
97
|
+
// The app is autocompleting an `@file` mention in its composer — the owner should
|
|
98
|
+
// list the cwd files matching `query` and reply via sendFileMatches(id, …).
|
|
99
|
+
onFilesSearch?: (id: string, query: string) => void;
|
|
96
100
|
// A controller (re)attached — the owner should push a transcript snapshot.
|
|
97
101
|
onControllerAttached?: () => void;
|
|
98
102
|
onStatus?: (text: string) => void;
|
|
@@ -139,7 +143,7 @@ export class RemoteBridge {
|
|
|
139
143
|
onSkillCreate: (skill, sig, ts) => this.cfg.onSkillCreate?.(skill, sig, ts),
|
|
140
144
|
onSkillDelete: (name, sig, ts) => this.cfg.onSkillDelete?.(name, sig, ts),
|
|
141
145
|
onSkillSetEnabled: (name, enabled, sig, ts) => this.cfg.onSkillSetEnabled?.(name, enabled, sig, ts),
|
|
142
|
-
// Routines are owned by the
|
|
146
|
+
// Routines are owned by the harbor, not an interactive session, so its own relay
|
|
143
147
|
// (not this bridge) handles routines_*. These no-ops just satisfy Required — an
|
|
144
148
|
// interactive terminal never surfaces the routines manager in the app.
|
|
145
149
|
onRoutinesList: () => {},
|
|
@@ -147,25 +151,25 @@ export class RemoteBridge {
|
|
|
147
151
|
onRoutinesDelete: () => {},
|
|
148
152
|
onRoutinesSetEnabled: () => {},
|
|
149
153
|
onRoutinesRun: () => {},
|
|
150
|
-
// Ad-hoc task spawns are
|
|
154
|
+
// Ad-hoc task spawns are harbor-owned too (they run on / are stood up by the harbor,
|
|
151
155
|
// not an interactive session), so its own relay handles task_submit/task_spawn. These
|
|
152
156
|
// no-ops just satisfy Required — an interactive terminal never receives them.
|
|
153
157
|
onTaskSubmit: () => {},
|
|
154
158
|
onTaskSpawn: () => {},
|
|
155
|
-
// Channels, like routines, are owned by the
|
|
156
|
-
// not an interactive session — the
|
|
159
|
+
// Channels, like routines, are owned by the harbor (its channels/run.ts config),
|
|
160
|
+
// not an interactive session — the harbor's own relay handles channels_*. These
|
|
157
161
|
// no-ops just satisfy Required; an interactive terminal never surfaces channels.
|
|
158
162
|
onChannelsList: () => {},
|
|
159
163
|
onChannelsSave: () => {},
|
|
160
164
|
onChannelsRemove: () => {},
|
|
161
|
-
// MCP connectors, like channels, are managed on the
|
|
162
|
-
// adapter) — the
|
|
165
|
+
// MCP connectors, like channels, are managed on the harbor (the host that runs the
|
|
166
|
+
// adapter) — the harbor's own relay handles mcp_*. These no-ops just satisfy Required;
|
|
163
167
|
// an interactive terminal manages MCP over IPC (desktop), never over this relay.
|
|
164
168
|
onMcpList: () => {},
|
|
165
169
|
onMcpSave: () => {},
|
|
166
170
|
onMcpSetEnabled: () => {},
|
|
167
171
|
onMcpRemove: () => {},
|
|
168
|
-
// Workflows, like routines/channels, are
|
|
172
|
+
// Workflows, like routines/channels, are harbor-owned — the harbor's own relay handles
|
|
169
173
|
// workflows_*. These no-ops just satisfy Required; an interactive terminal never
|
|
170
174
|
// surfaces workflows.
|
|
171
175
|
onWorkflowsList: () => {},
|
|
@@ -185,6 +189,7 @@ export class RemoteBridge {
|
|
|
185
189
|
const resolve = this.pendingInputs.get(id);
|
|
186
190
|
if (resolve) resolve(value);
|
|
187
191
|
},
|
|
192
|
+
onFilesSearch: (id, query) => this.cfg.onFilesSearch?.(id, query),
|
|
188
193
|
onNoQuarter: (on) => {
|
|
189
194
|
this.noQuarter = on;
|
|
190
195
|
this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
|
|
@@ -239,6 +244,11 @@ export class RemoteBridge {
|
|
|
239
244
|
this.relay?.sendCommands(commands);
|
|
240
245
|
}
|
|
241
246
|
|
|
247
|
+
// Reply to an app `@file` autocomplete query with the matching cwd entries.
|
|
248
|
+
sendFileMatches(id: string, matches: { path: string; isDir: boolean }[]): void {
|
|
249
|
+
this.relay?.sendFileMatches(id, matches);
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
// Push the installed-extensions snapshot to the app's extensions manager.
|
|
243
253
|
sendExtensions(payload: ExtensionsPayload): void {
|
|
244
254
|
this.relay?.sendExtensions(payload);
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* Routine management for the app.
|
|
3
3
|
*
|
|
4
4
|
* A UI-agnostic wrapper over the routines store so the app (over the relay) can
|
|
5
|
-
* see the
|
|
5
|
+
* see the harbor's saved routines and create / edit / delete / pause / run them —
|
|
6
6
|
* the sibling of extensionsControl.ts and skillsControl.ts, but for scheduled
|
|
7
7
|
* tasks rather than Pi packages/skills.
|
|
8
8
|
*
|
|
9
|
-
* Unlike those two, routines are owned by the
|
|
9
|
+
* Unlike those two, routines are owned by the HARBOR (not an interactive Pi
|
|
10
10
|
* session): they live in routines.json (see routines/store.ts) and fire from the
|
|
11
|
-
* resident scheduler. So this control is wired into the
|
|
11
|
+
* resident scheduler. So this control is wired into the harbor's own relay
|
|
12
12
|
* connection (the "Privateer Routines" terminal), not the REPL/TUI. Running a
|
|
13
|
-
* routine now is the one action that needs the
|
|
13
|
+
* routine now is the one action that needs the harbor itself, so it's injected as
|
|
14
14
|
* `runNow` rather than reaching back into the store.
|
|
15
15
|
*
|
|
16
16
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
@@ -75,7 +75,7 @@ export interface RoutinesControl {
|
|
|
75
75
|
remove(idOrName: string): { ok: boolean; message?: string };
|
|
76
76
|
// Pause/resume a routine. Resuming reschedules nextRun; pausing clears it.
|
|
77
77
|
setEnabled(idOrName: string, enabled: boolean): { ok: boolean; message?: string };
|
|
78
|
-
// Run a routine now (fire-and-forget on the
|
|
78
|
+
// Run a routine now (fire-and-forget on the harbor). ok:false when not found.
|
|
79
79
|
run(idOrName: string): { ok: boolean; message?: string };
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -122,11 +122,11 @@ function toRemote(r: Routine): RemoteRoutine {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
export function makeRoutinesControl(opts: {
|
|
125
|
-
// Working directory for a new routine when the draft omits `cwd` (the
|
|
125
|
+
// Working directory for a new routine when the draft omits `cwd` (the harbor's).
|
|
126
126
|
defaultCwd: () => string;
|
|
127
127
|
// Is a webhook name declared in config? Guards "webhook:<name>" delivery entries.
|
|
128
128
|
webhookExists?: (name: string) => boolean;
|
|
129
|
-
// Fire a routine now — injected by the
|
|
129
|
+
// Fire a routine now — injected by the harbor (its runRoutine). Absent → run is
|
|
130
130
|
// reported unavailable rather than silently dropped.
|
|
131
131
|
runNow?: (routine: Routine) => void;
|
|
132
132
|
}): RoutinesControl {
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The sibling of routinesControl.ts / channelsControl.ts, for declarative workflow
|
|
5
5
|
* graphs. UI-agnostic: nothing here imports React or the relay — the caller (the
|
|
6
|
-
*
|
|
7
|
-
* run seam. Like routines, workflows are owned by the
|
|
8
|
-
* scheduler / on-demand), so this control is wired into the
|
|
6
|
+
* harbor) owns the frame plumbing, the signed-frame gate (authorizeControl), and the
|
|
7
|
+
* run seam. Like routines, workflows are owned by the HARBOR (they run on its resident
|
|
8
|
+
* scheduler / on-demand), so this control is wired into the harbor's own relay.
|
|
9
9
|
*
|
|
10
10
|
* A workflow file is an EXECUTABLE artifact (it can carry `script` steps), so the
|
|
11
|
-
*
|
|
11
|
+
* harbor MUST verify the account signature on every mutating frame (workflows_save /
|
|
12
12
|
* remove / run) via guardControl BEFORE calling save/remove/run here — a forged save
|
|
13
13
|
* would plant a script step, a forged run would execute one. `list`/`get` are read-only.
|
|
14
14
|
*
|
|
@@ -48,7 +48,7 @@ export interface WorkflowsControl {
|
|
|
48
48
|
save(draft: unknown): { ok: boolean; message?: string; id?: string };
|
|
49
49
|
// Remove a workflow by id or name. ok:false when nothing matched.
|
|
50
50
|
remove(idOrName: string): { ok: boolean; message?: string };
|
|
51
|
-
// Run a workflow now (fire-and-forget on the
|
|
51
|
+
// Run a workflow now (fire-and-forget on the harbor). ok:false when not found or the
|
|
52
52
|
// runner isn't wired.
|
|
53
53
|
run(idOrName: string): { ok: boolean; message?: string };
|
|
54
54
|
}
|
|
@@ -72,7 +72,7 @@ function draftId(draft: unknown): string | undefined {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export function makeWorkflowsControl(opts: {
|
|
75
|
-
// Fire a workflow now — injected by the
|
|
75
|
+
// Fire a workflow now — injected by the harbor (it owns the runner + its seams).
|
|
76
76
|
// Absent → run is reported unavailable rather than silently dropped (mirrors routines).
|
|
77
77
|
runNow?: (wf: Workflow) => void;
|
|
78
78
|
}): WorkflowsControl {
|
package/src/routines/delivery.ts
CHANGED
|
@@ -2,13 +2,13 @@ import type { Routine } from "./schema.ts";
|
|
|
2
2
|
import { webhookName } from "./schema.ts";
|
|
3
3
|
import { writeRoutineOutput, addNotice } from "./store.ts";
|
|
4
4
|
|
|
5
|
-
// A relay pusher, injected by the
|
|
5
|
+
// A relay pusher, injected by the harbor. Given the finished result it either
|
|
6
6
|
// forwards it to an attached controller immediately ("live") or persists it to the
|
|
7
7
|
// pending-relay queue to flush when the app next attaches ("queued"). Either way the
|
|
8
8
|
// result is durably accounted for, so delivery doesn't add a notice backstop for it.
|
|
9
9
|
export type RelayPusher = (routine: Routine, content: string) => "live" | "queued";
|
|
10
10
|
|
|
11
|
-
// A cloud-outbox pusher, injected by the
|
|
11
|
+
// A cloud-outbox pusher, injected by the harbor. Seals the result to the account's
|
|
12
12
|
// outbox public key and POSTs the ciphertext to the server (E2EE store-and-forward),
|
|
13
13
|
// or buffers it durably on failure. Returns "sent" when the server accepted it,
|
|
14
14
|
// "queued" when it was persisted for a later flush — either way the result is
|
|
@@ -31,7 +31,7 @@ export interface DeliveryContext {
|
|
|
31
31
|
// Named endpoints for "webhook:<name>" delivery entries; results POST here.
|
|
32
32
|
webhooks?: Record<string, WebhookTarget>;
|
|
33
33
|
// Scrubs secrets from anything leaving the machine. Webhook bodies always pass
|
|
34
|
-
// through this when provided (the
|
|
34
|
+
// through this when provided (the harbor wires in redactText).
|
|
35
35
|
redact?: (text: string) => string;
|
|
36
36
|
// Injectable for tests; defaults to global fetch.
|
|
37
37
|
fetchImpl?: typeof fetch;
|
|
@@ -39,7 +39,7 @@ export interface DeliveryContext {
|
|
|
39
39
|
|
|
40
40
|
export interface DeliveryReport {
|
|
41
41
|
// Channels that actually delivered (email is handled inside the agent run, so it
|
|
42
|
-
// never appears here — see the
|
|
42
|
+
// never appears here — see the harbor). Failed webhooks show as
|
|
43
43
|
// "webhook:<name>(failed)" and leave a notice so the result isn't silently lost.
|
|
44
44
|
delivered: string[];
|
|
45
45
|
// Absolute path to latest.md when file delivery ran.
|
|
@@ -101,7 +101,7 @@ async function postWebhook(
|
|
|
101
101
|
// result is never silently lost. `webhook:<name>` POSTs the (redacted) result to a
|
|
102
102
|
// named endpoint from config; a failed or unconfigured webhook leaves a notice.
|
|
103
103
|
// `email` is intentionally not handled here: it is fulfilled inside the agent turn
|
|
104
|
-
// (the
|
|
104
|
+
// (the harbor adds the Gmail tool + an instruction to the prompt) so plaintext
|
|
105
105
|
// egress stays an explicit, gated action.
|
|
106
106
|
export async function deliver(
|
|
107
107
|
routine: Routine,
|
|
@@ -127,7 +127,7 @@ export async function deliver(
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// Relay: pushed live to an attached controller, or queued to flush when the app
|
|
130
|
-
// next attaches (the
|
|
130
|
+
// next attaches (the harbor persists that queue, so it's durable either way). Only
|
|
131
131
|
// when no pusher is wired at all do we fall back to a notice so it isn't lost.
|
|
132
132
|
if (wants.has("relay")) {
|
|
133
133
|
const pushed = ctx.pushRelay?.(routine, content);
|
package/src/routines/schema.ts
CHANGED
|
@@ -27,7 +27,7 @@ export function webhookName(entry: string): string | null {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// A saved, unattended agent task. Persisted in routines.json and executed by the
|
|
30
|
-
//
|
|
30
|
+
// harbor when its trigger comes due. A routine's trigger is EITHER recurring (a
|
|
31
31
|
// cron expression) or one-off (`at`, a specific datetime) — exactly one is set.
|
|
32
32
|
export const Routine = z
|
|
33
33
|
.object({
|
|
@@ -48,14 +48,14 @@ export const Routine = z
|
|
|
48
48
|
model: z.string().optional(),
|
|
49
49
|
// Where to deliver the result. Defaults to on-box file output.
|
|
50
50
|
delivery: z.array(DeliveryEntry).default(["file"]),
|
|
51
|
-
// Optional tool allow-subset. Unset → the safe read/web set (see
|
|
51
|
+
// Optional tool allow-subset. Unset → the safe read/web set (see harbor). Entries
|
|
52
52
|
// may be builtin names ("read") or MCP selectors — "<server>__<tool>" exact or
|
|
53
53
|
// "<server>__*" for a whole server (see routines/toolSelect.ts). Selected MCP
|
|
54
54
|
// tools run unattended under the auto-approve gate, so grant the minimum needed.
|
|
55
55
|
tools: z.array(z.string()).optional(),
|
|
56
56
|
// Paused routines stay in the file but never fire.
|
|
57
57
|
enabled: z.boolean().default(true),
|
|
58
|
-
// Bookkeeping, updated by the
|
|
58
|
+
// Bookkeeping, updated by the harbor after each run.
|
|
59
59
|
lastRun: z.string().optional(),
|
|
60
60
|
lastStatus: z.enum(["ok", "error"]).optional(),
|
|
61
61
|
lastError: z.string().optional(),
|
package/src/routines/store.ts
CHANGED
|
@@ -20,7 +20,7 @@ function slug(name: string): string {
|
|
|
20
20
|
return name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "routine";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// A stable relay terminal id for the
|
|
23
|
+
// A stable relay terminal id for the harbor, persisted so it reappears as the same
|
|
24
24
|
// "Privateer Routines" terminal in the app across restarts (rather than a fresh
|
|
25
25
|
// random terminal each boot). Random on first use so it stays unique per install —
|
|
26
26
|
// the relay routes on this id with no user namespacing, so a shared constant could
|
|
@@ -53,7 +53,7 @@ export function loadRoutines(): Routine[] {
|
|
|
53
53
|
try {
|
|
54
54
|
return RoutineFile.parse(JSON.parse(readFileSync(path, "utf8"))).routines;
|
|
55
55
|
} catch {
|
|
56
|
-
// A corrupt or hand-edited file shouldn't crash the
|
|
56
|
+
// A corrupt or hand-edited file shouldn't crash the harbor; treat as empty.
|
|
57
57
|
return [];
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -160,7 +160,7 @@ export function drainNotices(): RoutineNotice[] {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
// A relay result produced while no controller was attached, held until the app
|
|
163
|
-
// next connects. Persisted (not just in-memory) so it survives a
|
|
163
|
+
// next connects. Persisted (not just in-memory) so it survives a harbor restart.
|
|
164
164
|
export interface PendingRelay {
|
|
165
165
|
routine: string;
|
|
166
166
|
at: string; // ISO timestamp
|
package/src/routines/trigger.ts
CHANGED
|
@@ -16,7 +16,7 @@ export function triggerError(t: Trigger): string | null {
|
|
|
16
16
|
|
|
17
17
|
// The fire time to store as `nextRun`. For cron: the next match strictly after
|
|
18
18
|
// `from`. For a one-off: the fixed `at` time as-is (even if already past, so a
|
|
19
|
-
// missed one-off still fires once when the
|
|
19
|
+
// missed one-off still fires once when the harbor comes back). Null if unparseable.
|
|
20
20
|
export function computeNextRun(t: Trigger, from: Date = new Date()): Date | null {
|
|
21
21
|
if (t.cron) return cronNext(t.cron, from);
|
|
22
22
|
if (t.at) {
|
package/src/tools/routine.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The `create_routine` tool — lets the agent turn "summarize the news every morning"
|
|
2
|
-
// or "remind me at 3pm tomorrow" into a saved routine the scheduler
|
|
3
|
-
// unattended (see src/
|
|
2
|
+
// or "remind me at 3pm tomorrow" into a saved routine the scheduler harbor runs
|
|
3
|
+
// unattended (see src/harbor). Ported from tree-cli/src/tools/routine.ts, adapted to
|
|
4
4
|
// Pi's registerTool (TypeBox schema) with the in-tool ctx.gate.request removed: in
|
|
5
5
|
// the Pi model our permission-gate extension gates the tool_call itself (classify.ts
|
|
6
6
|
// gives it a "Create routine" prompt + flags email/webhook egress).
|
|
@@ -11,7 +11,7 @@ import { configPath } from "../config/paths.ts";
|
|
|
11
11
|
import { newRoutineId, webhookName, type Routine } from "../routines/schema.ts";
|
|
12
12
|
import { triggerError, computeNextRun, describeTrigger } from "../routines/trigger.ts";
|
|
13
13
|
import { upsertRoutine } from "../routines/store.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { sendToHarbor, HarborNotRunningError } from "../harbor/ipc.ts";
|
|
15
15
|
|
|
16
16
|
const KNOWN_CHANNELS = new Set(["file", "relay", "notice", "cloud", "email"]);
|
|
17
17
|
|
|
@@ -106,20 +106,20 @@ export const routineToolDefinition = {
|
|
|
106
106
|
nextRun: next?.toISOString(),
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
// Hand to the running
|
|
110
|
-
// file so the routine persists until the
|
|
109
|
+
// Hand to the running harbor (it validates + schedules); fall back to writing the
|
|
110
|
+
// file so the routine persists until the harbor starts.
|
|
111
111
|
try {
|
|
112
|
-
const res = await
|
|
112
|
+
const res = await sendToHarbor({ cmd: "add", routine });
|
|
113
113
|
if (!res.ok) return text(`Error saving routine: ${res.message ?? "unknown"}`);
|
|
114
114
|
return text(
|
|
115
115
|
`Created routine "${name}" (${describeTrigger({ cron, at })}). ` +
|
|
116
116
|
`Next run ${next ? next.toLocaleString() : "unknown"}, delivery: ${chans.join(", ")}.`,
|
|
117
117
|
);
|
|
118
118
|
} catch (e) {
|
|
119
|
-
if (e instanceof
|
|
119
|
+
if (e instanceof HarborNotRunningError) {
|
|
120
120
|
upsertRoutine(routine);
|
|
121
121
|
return text(
|
|
122
|
-
`Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler
|
|
122
|
+
`Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler harbor isn't ` +
|
|
123
123
|
`running yet, so it won't fire until you start it.`,
|
|
124
124
|
);
|
|
125
125
|
}
|
|
@@ -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 [];
|