@danypops/pi-packed 0.2.1 → 0.3.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 CHANGED
@@ -11,8 +11,8 @@ inside the supervised Bun daemon.
11
11
  ```text
12
12
  ┌─ Pi extension (Node-compatible) ──────────────┐
13
13
  │ pkg_search · pkg_info · pkg_install │
14
- │ /packages · session_start update notification
15
- confirmation gates · no Bun/SQLite dependency
14
+ │ /packages · /packed security settings
15
+ policy-driven approval · no Bun/SQLite access
16
16
  └──────────────────┬────────────────────────────┘
17
17
  │ authenticated loopback HTTP
18
18
  ┌─ packed.service (Bun) ────────────────────────┐
@@ -37,9 +37,7 @@ packed installed --json
37
37
  /packages
38
38
  ```
39
39
 
40
- Packages execute arbitrary code. `pkg_install` always requires interactive user
41
- confirmation before the extension sends the mutation to the authenticated
42
- daemon.
40
+ Packages execute arbitrary code. `pkg_install` uses daemon-owned security settings with secure default `installApproval: always`. Open `/packed` to choose **Always require approval** or explicitly opt out with **Never require approval**. The unsafe opt-out allows agent installs without an interactive UI. `/packages` remains the package browser.
43
41
 
44
42
  ## CLI
45
43
 
@@ -53,6 +51,7 @@ daemon.
53
51
  | `packed catalog [--json]` | Inspect the local package index |
54
52
  | `packed install <source> [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
55
53
  | `packed remove <name> [--json]` | Authenticated daemon removal by bare npm name |
54
+ | `packed security [always\|never] [--json]` | Read or set the install approval policy |
56
55
  | `packed serve` | Run the loopback daemon |
57
56
  | `packed service` | Print the systemd user unit |
58
57
  | `packed version` | Print the package/service version |
@@ -77,13 +76,15 @@ The daemon listens on loopback only.
77
76
  | `GET` | `/search?q=&limit=&offline=1` |
78
77
  | `GET` | `/info?name=` |
79
78
  | `GET` | `/installed` |
79
+ | `GET` | `/security` |
80
+ | `POST` | `/security` with `{ "installApproval": "always" | "never" }` |
80
81
  | `GET` | `/updates` |
81
82
  | `GET` | `/catalog` |
82
83
  | `POST` | `/install` with `{ "source": "..." }` |
83
84
  | `POST` | `/remove` with `{ "name": "..." }` |
84
85
 
85
86
  State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
86
- `updates.json`, and `packed.db`. Relevant environment variables:
87
+ `updates.json`, `security.json`, and `packed.db`. Relevant environment variables:
87
88
 
88
89
  - `PI_PACKED_HOME`
89
90
  - `PI_PACKED_PI_HOME`
@@ -13,6 +13,7 @@ import { registerTools } from "./tools.js";
13
13
  import { showPackages } from "./tui.js";
14
14
  import { createNatives } from "./packed.js";
15
15
  import { formatUpdateNotice } from "./model.js";
16
+ import { showPackedSettings } from "./security-tui.js";
16
17
 
17
18
  // Async factory (pi awaits it): the seam creates authenticated daemon
18
19
  // clients lazily. It never executes Bun-only adapters or opens SQLite.
@@ -20,6 +21,13 @@ export default async function (pi: ExtensionAPI) {
20
21
  const natives = await createNatives();
21
22
  registerTools(pi, natives);
22
23
 
24
+ pi.registerCommand("packed", {
25
+ description: "Configure pi-packed security settings",
26
+ handler: async (_args, ctx) => {
27
+ await showPackedSettings(ctx, natives);
28
+ },
29
+ });
30
+
23
31
  pi.registerCommand("packages", {
24
32
  description: "Browse and manage installed Pi packages (pi-packed)",
25
33
  handler: async (_args, ctx) => {
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { PackageDaemonPort as ClientPackageDaemonPort } from "../../src/client.ts";
10
10
  import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry } from "../../src/ports.ts";
11
+ import type { InstallApproval, SecuritySettings } from "../../src/security.ts";
11
12
 
12
13
  export type { InstalledPkg, UpdateEntry };
13
14
  export type PackageInfo = PkgInfo;
@@ -25,6 +26,8 @@ export interface Natives {
25
26
  info(name: string): Promise<PackageInfo>;
26
27
  installed(): Promise<InstalledPkg[]>;
27
28
  updates(): Promise<UpdateEntry[]>;
29
+ security(): Promise<SecuritySettings>;
30
+ setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
28
31
  install(source: string): Promise<string>;
29
32
  remove(name: string): Promise<string>;
30
33
  }
@@ -58,6 +61,8 @@ export async function createNatives(connect: PackageDaemonConnector = connectDef
58
61
  info: (name) => call((daemon) => daemon.info(name)),
59
62
  installed: () => call((daemon) => daemon.installed()),
60
63
  updates: () => call((daemon) => daemon.updates()),
64
+ security: () => call((daemon) => daemon.security()),
65
+ setInstallApproval: (value) => call((daemon) => daemon.setInstallApproval(value)),
61
66
  install: (source) => call((daemon) => daemon.install(source)),
62
67
  remove: (name) => call((daemon) => daemon.remove(name)),
63
68
  };
@@ -0,0 +1,33 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { InstallApproval } from "../../src/security.ts";
3
+ import type { Natives } from "./packed.ts";
4
+
5
+ const OPTIONS: Array<{ value: InstallApproval; label: string }> = [
6
+ { value: "always", label: "Always require approval (recommended)" },
7
+ { value: "never", label: "Never require approval (unsafe opt-out)" },
8
+ ];
9
+
10
+ export async function showPackedSettings(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
11
+ if (!ctx.hasUI) {
12
+ ctx.ui.notify("/packed requires interactive mode", "warning");
13
+ return;
14
+ }
15
+ try {
16
+ const current = await natives.security();
17
+ const choice = await ctx.ui.select(
18
+ `Package install approval · current: ${current.installApproval}`,
19
+ [...OPTIONS.map(({ label }) => label), "Cancel"],
20
+ );
21
+ const selected = OPTIONS.find(({ label }) => label === choice);
22
+ if (!selected || selected.value === current.installApproval) return;
23
+ const updated = await natives.setInstallApproval(selected.value);
24
+ ctx.ui.notify(
25
+ updated.installApproval === "always"
26
+ ? "Package installs now require confirmation."
27
+ : "Package install confirmation disabled. Packages can execute arbitrary code.",
28
+ updated.installApproval === "always" ? "info" : "warning",
29
+ );
30
+ } catch (error) {
31
+ ctx.ui.notify(`packed security settings failed: ${error instanceof Error ? error.message : error}`, "error");
32
+ }
33
+ }
@@ -10,6 +10,28 @@ function text(t: string, details: Record<string, unknown> = {}) {
10
10
  return { content: [{ type: "text" as const, text: t }], details };
11
11
  }
12
12
 
13
+ export async function installPackageWithPolicy(
14
+ source: string,
15
+ natives: Pick<Natives, "security" | "install">,
16
+ ctx: { hasUI: boolean; ui: { confirm(title: string, message: string): Promise<boolean> } },
17
+ ) {
18
+ try {
19
+ const { installApproval } = await natives.security();
20
+ if (installApproval === "always") {
21
+ if (!ctx.hasUI) return text("pkg_install requires interactive approval; change installApproval in /packed to opt out.");
22
+ const ok = await ctx.ui.confirm(
23
+ "Install Pi package",
24
+ `Run: pi install ${source}\n\nPackages execute arbitrary code. Continue?`,
25
+ );
26
+ if (!ok) return text("Install cancelled by user.");
27
+ }
28
+ const out = await natives.install(source);
29
+ return text(out || `Installed ${source}. Reload with /reload to activate.`);
30
+ } catch (error) {
31
+ return text(`install failed: ${error instanceof Error ? error.message : error}`);
32
+ }
33
+ }
34
+
13
35
  export function registerTools(pi: ExtensionAPI, natives: Natives): void {
14
36
  pi.registerTool({
15
37
  name: "pkg_search",
@@ -71,25 +93,12 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
71
93
  label: "Pi Package Install",
72
94
  description:
73
95
  "Install a Pi package (pi install). Supports npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], " +
74
- "or https:// URLs. Packages execute arbitrary code the user confirms every install.",
96
+ "or https:// URLs. Packages execute arbitrary code. Approval follows the installApproval policy configured in /packed (secure default: always).",
75
97
  parameters: Type.Object({
76
98
  source: Type.String({ description: "e.g. 'npm:pi-lsp', 'npm:@scope/pkg@1.2.3', 'git:github.com/u/r@v1'" }),
77
99
  }),
78
100
  async execute(_id, params, _signal, _onUpdate, ctx) {
79
- if (!ctx.hasUI) {
80
- return text("pkg_install requires an interactive session (the user must confirm).");
81
- }
82
- const ok = await ctx.ui.confirm(
83
- "Install Pi package",
84
- `Run: pi install ${params.source}\n\nPackages execute arbitrary code. Continue?`,
85
- );
86
- if (!ok) return text("Install cancelled by user.");
87
- try {
88
- const out = await natives.install(params.source);
89
- return text(out || `Installed ${params.source}. Reload with /reload to activate.`);
90
- } catch (e) {
91
- return text(`install failed: ${e instanceof Error ? e.message : e}`);
92
- }
101
+ return installPackageWithPolicy(params.source, natives, ctx);
93
102
  },
94
103
  });
95
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Package service for the Pi agent: search/info/install/updates + /packages TUI, backed by a long-running Bun service",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/cli.ts CHANGED
@@ -11,6 +11,7 @@ import { checkUpdates } from "./watcher.ts";
11
11
  import { syncCatalog } from "./catalog.ts";
12
12
  import { openDb, searchLocal, catalogList, getSyncMeta, latestVersion, dbPath } from "./db.ts";
13
13
  import { NAME_RE, defaultPiBin } from "./install.ts";
14
+ import type { InstallApproval, SecuritySettingsPort } from "./security.ts";
14
15
 
15
16
  function defaultPiBinForUnit(): string | undefined {
16
17
  const b = defaultPiBin();
@@ -33,6 +34,7 @@ usage:
33
34
  packed catalog [--json] local package index (apt-cache stats)
34
35
  packed install <source> [--json] pi install npm:|git:|https://… via daemon
35
36
  packed remove <name> [--json] remove by bare npm name via daemon
37
+ packed security [always|never] [--json] read or set install approval policy
36
38
  packed serve run the long-running daemon
37
39
  packed service print a systemd user unit
38
40
  packed version print version
@@ -41,6 +43,7 @@ usage:
41
43
  export interface CliDeps {
42
44
  reg: Registry;
43
45
  inst: Installer;
46
+ security: SecuritySettingsPort;
44
47
  stateDir: string;
45
48
  piHome: string;
46
49
  execPath?: string; // bun binary (defaults to process.execPath)
@@ -199,6 +202,22 @@ const commands: Record<string, { usage: string; run: Command }> = {
199
202
  },
200
203
  },
201
204
 
205
+ security: {
206
+ usage: "packed security [always|never] [--json]",
207
+ async run(_rest, d, flags, pos) {
208
+ const requested = pos[0];
209
+ if (requested !== undefined && requested !== "always" && requested !== "never") {
210
+ return usageErr(`usage: ${commands["security"]!.usage}\n`);
211
+ }
212
+ const settings = requested
213
+ ? await d.security.setInstallApproval(requested as InstallApproval)
214
+ : await d.security.security();
215
+ return flags.json
216
+ ? ok(`${JSON.stringify(settings)}\n`)
217
+ : ok(`install approval: ${settings.installApproval}\n`);
218
+ },
219
+ },
220
+
202
221
  remove: {
203
222
  usage: "packed remove <name> [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
204
223
  async run(_rest, d, flags, pos) {
@@ -277,7 +296,7 @@ if (import.meta.main) {
277
296
  } else {
278
297
  const { stateDir } = await import("./state.ts");
279
298
  const { defaultPiHome } = await import("./installed.ts");
280
- const { DaemonBackedInstaller, resolveRegistry } = await import("./client.ts");
299
+ const { DaemonBackedSecurity, DaemonBackedInstaller, resolveRegistry } = await import("./client.ts");
281
300
  const dir = stateDir();
282
301
  // mirror talks to UPSTREAM, not the daemon cache — apt update semantics.
283
302
  const reg =
@@ -287,6 +306,7 @@ if (import.meta.main) {
287
306
  const { code, out } = await cliRun(args, {
288
307
  reg,
289
308
  inst: new DaemonBackedInstaller(dir),
309
+ security: new DaemonBackedSecurity(dir),
290
310
  stateDir: dir,
291
311
  piHome: defaultPiHome(),
292
312
  });
package/src/client.ts CHANGED
@@ -8,6 +8,7 @@ import { join } from "node:path";
8
8
  import type { InstalledPkg, Installer, PkgInfo, Registry, SearchPage, UpdateEntry, UpdatesSnapshot } from "./ports.ts";
9
9
  import { HttpRegistry } from "./registry.ts";
10
10
  import { DAEMON_HOST, PROBE_TIMEOUT_MS, REGISTRY_FETCH_TIMEOUT_MS, PORT_FILE, TOKEN_FILE } from "./constants.ts";
11
+ import type { InstallApproval, SecuritySettings } from "./security.ts";
11
12
 
12
13
  export type FetchTransport = (request: Request) => Promise<Response>;
13
14
 
@@ -16,6 +17,8 @@ export interface PackageDaemonPort {
16
17
  info(name: string): Promise<PkgInfo>;
17
18
  installed(): Promise<InstalledPkg[]>;
18
19
  updates(): Promise<UpdateEntry[]>;
20
+ security(): Promise<SecuritySettings>;
21
+ setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
19
22
  install(source: string): Promise<string>;
20
23
  remove(name: string): Promise<string>;
21
24
  }
@@ -85,6 +88,14 @@ export class PackageDaemonClient implements PackageDaemonPort {
85
88
  return (await this.request<UpdatesSnapshot>("/updates")).updates;
86
89
  }
87
90
 
91
+ security(): Promise<SecuritySettings> {
92
+ return this.request("/security");
93
+ }
94
+
95
+ setInstallApproval(installApproval: InstallApproval): Promise<SecuritySettings> {
96
+ return this.request("/security", { method: "POST", body: JSON.stringify({ installApproval }) });
97
+ }
98
+
88
99
  async install(source: string): Promise<string> {
89
100
  const result = await this.request<MutationResponse>("/install", {
90
101
  method: "POST",
@@ -119,6 +130,16 @@ export class PackageDaemonInstaller implements Installer {
119
130
  }
120
131
  }
121
132
 
133
+ export class DaemonBackedSecurity {
134
+ constructor(private readonly stateDirectory: string) {}
135
+ async security(): Promise<SecuritySettings> {
136
+ return (await connectPackageDaemon(this.stateDirectory)).security();
137
+ }
138
+ async setInstallApproval(value: InstallApproval): Promise<SecuritySettings> {
139
+ return (await connectPackageDaemon(this.stateDirectory)).setInstallApproval(value);
140
+ }
141
+ }
142
+
122
143
  export class DaemonBackedInstaller implements Installer {
123
144
  constructor(private readonly stateDirectory: string) {}
124
145
 
package/src/constants.ts CHANGED
@@ -31,7 +31,7 @@ export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
31
31
  export const WATCHDOG_TICK_MS = 15_000;
32
32
 
33
33
  // --- Identity ---
34
- export const VERSION = "0.2.1";
34
+ export const VERSION = "0.3.0";
35
35
 
36
36
  // --- State-dir file names ---
37
37
  export const TOKEN_FILE = "token";
@@ -39,6 +39,7 @@ export const PORT_FILE = "port";
39
39
  export const UPDATES_FILE = "updates.json";
40
40
  export const DB_FILE = "packed.db";
41
41
  export const SETTINGS_FILE = "settings.json";
42
+ export const SECURITY_FILE = "security.json";
42
43
 
43
44
  // --- Environment knobs ---
44
45
  export const ENV = {
@@ -0,0 +1,34 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { SECURITY_FILE } from "./constants.ts";
4
+
5
+ export const INSTALL_APPROVAL_VALUES = ["always", "never"] as const;
6
+ export type InstallApproval = typeof INSTALL_APPROVAL_VALUES[number];
7
+ export interface SecuritySettings { installApproval: InstallApproval }
8
+ export interface SecuritySettingsPort {
9
+ security(): Promise<SecuritySettings>;
10
+ setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
11
+ }
12
+
13
+ export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { installApproval: "always" };
14
+
15
+ export function readSecuritySettings(stateDir: string): SecuritySettings {
16
+ try {
17
+ const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as { installApproval?: unknown };
18
+ return INSTALL_APPROVAL_VALUES.includes(value.installApproval as InstallApproval)
19
+ ? { installApproval: value.installApproval as InstallApproval }
20
+ : { ...DEFAULT_SECURITY_SETTINGS };
21
+ } catch {
22
+ return { ...DEFAULT_SECURITY_SETTINGS };
23
+ }
24
+ }
25
+
26
+ export function writeSecuritySettings(stateDir: string, settings: SecuritySettings): SecuritySettings {
27
+ if (!INSTALL_APPROVAL_VALUES.includes(settings.installApproval)) throw new Error("installApproval must be always or never");
28
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
29
+ const target = join(stateDir, SECURITY_FILE);
30
+ const temporary = `${target}.tmp`;
31
+ writeFileSync(temporary, `${JSON.stringify(settings)}\n`, { mode: 0o600 });
32
+ renameSync(temporary, target);
33
+ return { ...settings };
34
+ }
package/src/service.ts CHANGED
@@ -11,6 +11,7 @@ import { readInstalledPackages, defaultPiHome } from "./installed.ts";
11
11
  import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
12
12
  import { VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
13
13
  import { createLogger } from "./log.ts";
14
+ import { readSecuritySettings, writeSecuritySettings, type InstallApproval } from "./security.ts";
14
15
 
15
16
  const log = createLogger("service");
16
17
 
@@ -45,6 +46,23 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
45
46
  return json({ ok: true, version: VERSION });
46
47
  }
47
48
 
49
+ if (path === "/security" && req.method === "GET") {
50
+ return json(readSecuritySettings(deps.stateDir));
51
+ }
52
+
53
+ if (path === "/security" && req.method === "POST") {
54
+ let installApproval: unknown;
55
+ try {
56
+ installApproval = ((await req.json()) as { installApproval?: unknown }).installApproval;
57
+ } catch {
58
+ return err(400, "invalid security settings JSON");
59
+ }
60
+ if (installApproval !== "always" && installApproval !== "never") {
61
+ return err(400, "installApproval must be always or never");
62
+ }
63
+ return json(writeSecuritySettings(deps.stateDir, { installApproval: installApproval as InstallApproval }));
64
+ }
65
+
48
66
  if (path === "/search" && req.method === "GET") {
49
67
  const q = url.searchParams.get("q") ?? "";
50
68
  const limit = clampLimit(Number(url.searchParams.get("limit")), SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
@@ -143,7 +161,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
143
161
  return err(401, "missing or invalid bearer token");
144
162
  }
145
163
  // Cache successful GETs by URI (smart-proxy concern).
146
- if (req.method === "GET" && !["/health", "/updates", "/catalog"].includes(new URL(req.url).pathname)) {
164
+ if (req.method === "GET" && !["/health", "/updates", "/catalog", "/security"].includes(new URL(req.url).pathname)) {
147
165
  const hit = cache.get(req.url);
148
166
  if (hit) {
149
167
  log.debug("request", { path: new URL(req.url).pathname, cache: "hit", ms: Date.now() - t0 });