privateer-agent 0.4.1 → 0.5.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/package.json +1 -1
- package/src/config/hosted.ts +13 -0
- package/src/daemon/index.ts +81 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Harbor hosted mode.
|
|
2
|
+
//
|
|
3
|
+
// When true, this daemon is running inside Privateer's confidential-VM fleet
|
|
4
|
+
// (the host orchestrator sets HARBOR_HOSTED=1), not on a user's own machine.
|
|
5
|
+
// Hosted daemons run on-demand: they report their next routine fire time to the
|
|
6
|
+
// server and idle-suspend when there's no work, so the server can wake them
|
|
7
|
+
// again in time. A daemon on a user's laptop leaves this off and keeps running
|
|
8
|
+
// its own cron continuously.
|
|
9
|
+
//
|
|
10
|
+
// Read via process.env at call time, mirroring PRIVATEER_HOME / PRIVATEER_SERVER_URL.
|
|
11
|
+
export function isHosted(): boolean {
|
|
12
|
+
return process.env.HARBOR_HOSTED === "1";
|
|
13
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
|
|
|
50
50
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
51
51
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
52
52
|
import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
53
|
+
import { isHosted } from "../config/hosted.ts";
|
|
53
54
|
|
|
54
55
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
55
56
|
// write/edit/bash, so a routine firing with nobody watching can't mutate the
|
|
@@ -59,6 +60,10 @@ import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
|
59
60
|
const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
60
61
|
|
|
61
62
|
const TICK_MS = 60_000; // scan for due routines once a minute
|
|
63
|
+
// Harbor hosted mode (isHosted): suspend after this much idle time with no work,
|
|
64
|
+
// and stay up if a routine is due within the lead window (avoids suspend→wake churn).
|
|
65
|
+
const HOSTED_IDLE_MS = Number(process.env.HARBOR_IDLE_MS) || 5 * 60_000;
|
|
66
|
+
const HOSTED_SUSPEND_MIN_LEAD_MS = Number(process.env.HARBOR_SUSPEND_MIN_LEAD_MS) || 2 * 60_000;
|
|
62
67
|
const MAX_CLOUD_PLAINTEXT = 45_000;
|
|
63
68
|
// How long a workflow `human_gate` (or a script-approval prompt) waits for the app to
|
|
64
69
|
// answer before it fail-closes to "no response" (the runner then defers the run). Bounds
|
|
@@ -158,6 +163,11 @@ export class Daemon {
|
|
|
158
163
|
private relay?: RelayClient;
|
|
159
164
|
private controllerAttached = false;
|
|
160
165
|
private relayTerminated = false;
|
|
166
|
+
// Harbor hosted mode: last time a controller attached or a routine ran. Drives
|
|
167
|
+
// idle-suspend (no `controller_detached` frame exists, so we gate on inactivity
|
|
168
|
+
// + no live work rather than on controllerAttached, which never resets while the
|
|
169
|
+
// socket stays open).
|
|
170
|
+
private lastActivityAt = Date.now();
|
|
161
171
|
// Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
|
|
162
172
|
// terminal (task-<uuid>); the daemon just keeps handles so it can reap them on shutdown.
|
|
163
173
|
private readonly liveTasks = new Map<string, LiveTaskHandle>();
|
|
@@ -406,6 +416,8 @@ export class Daemon {
|
|
|
406
416
|
|
|
407
417
|
private onControllerAttached(): void {
|
|
408
418
|
this.controllerAttached = true;
|
|
419
|
+
this.markActivity(); // hosted: a driver is present — reset the idle timer
|
|
420
|
+
|
|
409
421
|
this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
|
|
410
422
|
// Version + this terminal's identity public key (so the app can confirm this is
|
|
411
423
|
// the terminal it PINNED at link time before sealing channel tokens to it). No
|
|
@@ -506,6 +518,74 @@ export class Daemon {
|
|
|
506
518
|
await this.runRoutine(r);
|
|
507
519
|
}
|
|
508
520
|
}
|
|
521
|
+
await this.hostedTick();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── Harbor hosted mode ──────────────────────────────────────────────────────
|
|
525
|
+
// A hosted daemon runs on-demand: it reports its earliest upcoming fire time so
|
|
526
|
+
// the server can wake it while suspended, and idle-suspends when there's no work.
|
|
527
|
+
// No-op on a user's own machine (isHosted() === false).
|
|
528
|
+
|
|
529
|
+
private markActivity(): void { this.lastActivityAt = Date.now(); }
|
|
530
|
+
|
|
531
|
+
// Earliest fire time (ms) across enabled, valid routines — mirrors tick()'s fire
|
|
532
|
+
// filters so we never report/wait on a routine the scheduler won't actually fire.
|
|
533
|
+
private earliestFireMs(): number | null {
|
|
534
|
+
let earliest: number | null = null;
|
|
535
|
+
for (const r of loadRoutines()) {
|
|
536
|
+
if (!r.enabled || triggerError(r)) continue;
|
|
537
|
+
const nrStr = r.nextRun ?? computeNextRun(r)?.toISOString();
|
|
538
|
+
if (!nrStr) continue;
|
|
539
|
+
const t = Date.parse(nrStr);
|
|
540
|
+
if (Number.isNaN(t)) continue;
|
|
541
|
+
if (earliest === null || t < earliest) earliest = t;
|
|
542
|
+
}
|
|
543
|
+
return earliest;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Report our next fire time to the server so its scheduler can wake us. When
|
|
547
|
+
// `suspending`, also flip the server-side status to suspended (we're about to
|
|
548
|
+
// exit) so the sweeper knows to wake us again. Best-effort: an offline instance
|
|
549
|
+
// retries next tick; a suspend report failing means we stay up this tick.
|
|
550
|
+
private async reportSchedule(suspending = false): Promise<boolean> {
|
|
551
|
+
if (!isHosted() || !hasCredentials()) return true;
|
|
552
|
+
const earliest = this.earliestFireMs();
|
|
553
|
+
try {
|
|
554
|
+
const res = await apiRequest("/api/harbor/agent/schedule", {
|
|
555
|
+
method: "POST",
|
|
556
|
+
headers: { "content-type": "application/json" },
|
|
557
|
+
body: JSON.stringify({
|
|
558
|
+
termId: routineRelayId(),
|
|
559
|
+
nextRoutineAt: earliest !== null ? new Date(earliest).toISOString() : null,
|
|
560
|
+
...(suspending ? { suspended: true } : {}),
|
|
561
|
+
}),
|
|
562
|
+
});
|
|
563
|
+
return res.ok;
|
|
564
|
+
} catch {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private shouldSuspend(): boolean {
|
|
570
|
+
if (this.running.size > 0 || this.liveTasks.size > 0) return false;
|
|
571
|
+
if (Date.now() - this.lastActivityAt < HOSTED_IDLE_MS) return false;
|
|
572
|
+
const earliest = this.earliestFireMs();
|
|
573
|
+
if (earliest !== null && earliest - Date.now() < HOSTED_SUSPEND_MIN_LEAD_MS) return false;
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private async hostedTick(): Promise<void> {
|
|
578
|
+
if (!isHosted()) return;
|
|
579
|
+
if (this.shouldSuspend()) {
|
|
580
|
+
// Tell the server we're suspending (+ our next fire) BEFORE exiting; only
|
|
581
|
+
// then tear down. If the report fails, stay up and try again next tick.
|
|
582
|
+
if (!(await this.reportSchedule(true))) return;
|
|
583
|
+
log("hosted: idle — suspending (server will wake for the next routine)");
|
|
584
|
+
this.stop();
|
|
585
|
+
void revokeLocalSessions().finally(() => process.exit(0));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
await this.reportSchedule();
|
|
509
589
|
}
|
|
510
590
|
|
|
511
591
|
// Execute a routine to completion and deliver the result. The one rewired seam:
|
|
@@ -514,6 +594,7 @@ export class Daemon {
|
|
|
514
594
|
async runRoutine(routine: Routine): Promise<IpcResponse> {
|
|
515
595
|
if (this.running.has(routine.id)) return { ok: false, message: "already running" };
|
|
516
596
|
this.running.add(routine.id);
|
|
597
|
+
this.markActivity(); // hosted: work in progress — don't idle-suspend under it
|
|
517
598
|
log(`running routine "${routine.name}"`);
|
|
518
599
|
|
|
519
600
|
const config = loadDaemonConfig();
|