@danypops/pi-packed 0.3.0 → 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/README.md +14 -11
- package/extension/src/index.ts +1 -1
- package/extension/src/packed.ts +9 -7
- package/extension/src/security-tui.ts +18 -11
- package/extension/src/tools.ts +97 -43
- package/extension/src/tui.ts +19 -11
- package/package.json +1 -1
- package/src/cli.ts +60 -11
- package/src/client.ts +38 -20
- package/src/constants.ts +0 -1
- package/src/install.ts +6 -2
- package/src/ports.ts +3 -2
- package/src/security.ts +70 -9
- package/src/service.ts +66 -13
- package/src/version.ts +16 -0
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@ inside the supervised Bun daemon.
|
|
|
10
10
|
|
|
11
11
|
```text
|
|
12
12
|
┌─ Pi extension (Node-compatible) ──────────────┐
|
|
13
|
-
│ pkg_search · pkg_info · pkg_install
|
|
14
|
-
│ /packages · /packed
|
|
15
|
-
│
|
|
13
|
+
│ pkg_search · pkg_info · pkg_install/update/remove│
|
|
14
|
+
│ /packages · /packed permission settings │
|
|
15
|
+
│ operation-aware approval · no Bun/SQLite access │
|
|
16
16
|
└──────────────────┬────────────────────────────┘
|
|
17
17
|
│ authenticated loopback HTTP
|
|
18
18
|
┌─ packed.service (Bun) ────────────────────────┐
|
|
@@ -37,7 +37,7 @@ packed installed --json
|
|
|
37
37
|
/packages
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Packages execute arbitrary code.
|
|
40
|
+
Packages execute arbitrary code and mutate Pi settings/install roots. One daemon-owned operation policy classifies every public package operation. Install, update, remove, and security-setting changes require explicit approval by default (`mutationApproval: always`); search, info, installed, catalog, and update-status reads are bounded reads, while mirror refresh is classified maintenance. Open `/packed` to retain the recommended approval policy or deliberately choose the unsafe **Never require mutation approval** opt-out. `/packages` uses the same policy for updates and removals.
|
|
41
41
|
|
|
42
42
|
## CLI
|
|
43
43
|
|
|
@@ -46,17 +46,18 @@ Packages execute arbitrary code. `pkg_install` uses daemon-owned security settin
|
|
|
46
46
|
| `packed search <q> [--offline] [--limit N] [--json]` | Search npm or the local mirror, scoped to `keywords:pi-package` |
|
|
47
47
|
| `packed info <name> [--json]` | Show version, repository, Pi manifest, size, and license |
|
|
48
48
|
| `packed updates [--json]` | Show drift from the local mirror |
|
|
49
|
+
| `packed update <source> [--approve] [--json]` | Update one configured source through `pi update --extension` |
|
|
49
50
|
| `packed mirror [--json]` | Refresh the SQLite package index |
|
|
50
51
|
| `packed installed [--json]` | Read Pi's installed package declarations |
|
|
51
52
|
| `packed catalog [--json]` | Inspect the local package index |
|
|
52
|
-
| `packed install <source> [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
|
|
53
|
-
| `packed remove <name> [--json]` | Authenticated daemon removal by bare npm name |
|
|
54
|
-
| `packed security [always\|never] [--json]` | Read or set the
|
|
53
|
+
| `packed install <source> [--approve] [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
|
|
54
|
+
| `packed remove <name> [--approve] [--json]` | Authenticated daemon removal by bare npm name |
|
|
55
|
+
| `packed security [always\|never] [--approve] [--json]` | Read or set the package mutation approval policy |
|
|
55
56
|
| `packed serve` | Run the loopback daemon |
|
|
56
57
|
| `packed service` | Print the systemd user unit |
|
|
57
58
|
| `packed version` | Print the package/service version |
|
|
58
59
|
|
|
59
|
-
Install/remove JSON results are stable objects:
|
|
60
|
+
Guarded CLI mutations require `--approve` under the secure default. This is pi-packed mutation authorization, distinct from Pi's project-trust `--approve` semantics. Install/remove JSON results are stable objects:
|
|
60
61
|
|
|
61
62
|
```json
|
|
62
63
|
{"ok":true,"source":"npm:pi-lsp","output":"Installed npm:pi-lsp"}
|
|
@@ -77,11 +78,12 @@ The daemon listens on loopback only.
|
|
|
77
78
|
| `GET` | `/info?name=` |
|
|
78
79
|
| `GET` | `/installed` |
|
|
79
80
|
| `GET` | `/security` |
|
|
80
|
-
| `POST` | `/security` with `{ "
|
|
81
|
+
| `POST` | `/security` with `{ "mutationApproval": "always" | "never", "approved": true }` |
|
|
81
82
|
| `GET` | `/updates` |
|
|
82
83
|
| `GET` | `/catalog` |
|
|
83
|
-
| `POST` | `/install` with `{ "source": "..." }` |
|
|
84
|
-
| `POST` | `/
|
|
84
|
+
| `POST` | `/install` with `{ "source": "...", "approved": true }` |
|
|
85
|
+
| `POST` | `/update` with `{ "source": "...", "approved": true }` |
|
|
86
|
+
| `POST` | `/remove` with `{ "name": "...", "approved": true }` |
|
|
85
87
|
|
|
86
88
|
State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
|
|
87
89
|
`updates.json`, `security.json`, and `packed.db`. Relevant environment variables:
|
|
@@ -102,6 +104,7 @@ State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
|
|
|
102
104
|
loopback API and reconnect after daemon restarts.
|
|
103
105
|
- **Ports and adapters:** registry and installer ports keep policy independent
|
|
104
106
|
from npm, SQLite, subprocess, HTTP, and UI adapters.
|
|
107
|
+
- **Operation-aware authorization:** one policy matrix classifies reads, maintenance, code execution, settings mutation, and security mutation; guarded daemon routes reject missing approval with stable `approval_required` errors.
|
|
105
108
|
- **Allowlisted mutation input:** package sources and names reject shell
|
|
106
109
|
metacharacters before reaching the installer.
|
|
107
110
|
- **Bounded requests:** daemon calls use timeouts and return structured errors
|
package/extension/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-packed — Pi extension seam.
|
|
3
3
|
*
|
|
4
|
-
* Thin by design: registers agent tools (pkg_search/pkg_info/pkg_install),
|
|
4
|
+
* Thin by design: registers agent tools (pkg_search/pkg_info/pkg_install/pkg_update/pkg_remove),
|
|
5
5
|
* the /packages command, and a session_start update notification. ALL logic
|
|
6
6
|
* lives in the Bun service (src/): registry access, caching, watcher,
|
|
7
7
|
* catalog sync, install execution.
|
package/extension/src/packed.ts
CHANGED
|
@@ -8,7 +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 {
|
|
11
|
+
import type { MutationApproval, SecuritySettings } from "../../src/security.ts";
|
|
12
12
|
|
|
13
13
|
export type { InstalledPkg, UpdateEntry };
|
|
14
14
|
export type PackageInfo = PkgInfo;
|
|
@@ -27,9 +27,10 @@ export interface Natives {
|
|
|
27
27
|
installed(): Promise<InstalledPkg[]>;
|
|
28
28
|
updates(): Promise<UpdateEntry[]>;
|
|
29
29
|
security(): Promise<SecuritySettings>;
|
|
30
|
-
|
|
31
|
-
install(source: string): Promise<string>;
|
|
32
|
-
remove(name: string): Promise<string>;
|
|
30
|
+
setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
|
|
31
|
+
install(source: string, approved?: boolean): Promise<string>;
|
|
32
|
+
remove(name: string, approved?: boolean): Promise<string>;
|
|
33
|
+
update(source: string, approved?: boolean): Promise<string>;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
|
|
@@ -62,8 +63,9 @@ export async function createNatives(connect: PackageDaemonConnector = connectDef
|
|
|
62
63
|
installed: () => call((daemon) => daemon.installed()),
|
|
63
64
|
updates: () => call((daemon) => daemon.updates()),
|
|
64
65
|
security: () => call((daemon) => daemon.security()),
|
|
65
|
-
|
|
66
|
-
install: (source) => call((daemon) => daemon.install(source)),
|
|
67
|
-
remove: (name) => call((daemon) => daemon.remove(name)),
|
|
66
|
+
setMutationApproval: (value, approved) => call((daemon) => daemon.setMutationApproval(value, approved)),
|
|
67
|
+
install: (source, approved) => call((daemon) => daemon.install(source, approved)),
|
|
68
|
+
remove: (name, approved) => call((daemon) => daemon.remove(name, approved)),
|
|
69
|
+
update: (source, approved) => call((daemon) => daemon.update(source, approved)),
|
|
68
70
|
};
|
|
69
71
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type {
|
|
2
|
+
import type { MutationApproval } from "../../src/security.ts";
|
|
3
3
|
import type { Natives } from "./packed.ts";
|
|
4
4
|
|
|
5
|
-
const OPTIONS: Array<{ value:
|
|
6
|
-
{ value: "always", label: "Always require approval (recommended)" },
|
|
7
|
-
{ value: "never", label: "Never require approval (unsafe opt-out)" },
|
|
5
|
+
const OPTIONS: Array<{ value: MutationApproval; label: string }> = [
|
|
6
|
+
{ value: "always", label: "Always require mutation approval (recommended)" },
|
|
7
|
+
{ value: "never", label: "Never require mutation approval (unsafe opt-out)" },
|
|
8
8
|
];
|
|
9
9
|
|
|
10
10
|
export async function showPackedSettings(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
|
|
@@ -15,17 +15,24 @@ export async function showPackedSettings(ctx: ExtensionCommandContext, natives:
|
|
|
15
15
|
try {
|
|
16
16
|
const current = await natives.security();
|
|
17
17
|
const choice = await ctx.ui.select(
|
|
18
|
-
`Package
|
|
18
|
+
`Package mutation approval · current: ${current.mutationApproval}`,
|
|
19
19
|
[...OPTIONS.map(({ label }) => label), "Cancel"],
|
|
20
20
|
);
|
|
21
21
|
const selected = OPTIONS.find(({ label }) => label === choice);
|
|
22
|
-
if (!selected || selected.value === current.
|
|
23
|
-
const
|
|
22
|
+
if (!selected || selected.value === current.mutationApproval) return;
|
|
23
|
+
const approved = await ctx.ui.confirm(
|
|
24
|
+
"Change package mutation approval",
|
|
25
|
+
selected.value === "never"
|
|
26
|
+
? "Disable confirmation for install, update, remove, and package security changes? Packages can execute arbitrary code."
|
|
27
|
+
: "Restore confirmation for install, update, remove, and package security changes?",
|
|
28
|
+
);
|
|
29
|
+
if (!approved) return;
|
|
30
|
+
const updated = await natives.setMutationApproval(selected.value, true);
|
|
24
31
|
ctx.ui.notify(
|
|
25
|
-
updated.
|
|
26
|
-
? "Package
|
|
27
|
-
: "Package
|
|
28
|
-
updated.
|
|
32
|
+
updated.mutationApproval === "always"
|
|
33
|
+
? "Package mutations now require confirmation."
|
|
34
|
+
: "Package mutation confirmation disabled. Packages can execute arbitrary code.",
|
|
35
|
+
updated.mutationApproval === "always" ? "info" : "warning",
|
|
29
36
|
);
|
|
30
37
|
} catch (error) {
|
|
31
38
|
ctx.ui.notify(`packed security settings failed: ${error instanceof Error ? error.message : error}`, "error");
|
package/extension/src/tools.ts
CHANGED
|
@@ -1,61 +1,103 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tools.ts — agent-facing tools. Thin by design: descriptions teach the
|
|
3
|
-
* agent the packed CLI surface; logic lives in the Bun service.
|
|
4
|
-
*/
|
|
1
|
+
/** Agent-facing package tools over the authenticated daemon. */
|
|
5
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
3
|
import { Type } from "typebox";
|
|
4
|
+
import { packagePermissionDecision, type PackageOperation } from "../../src/security.ts";
|
|
7
5
|
import type { Natives } from "./packed.js";
|
|
8
6
|
|
|
9
7
|
function text(t: string, details: Record<string, unknown> = {}) {
|
|
10
8
|
return { content: [{ type: "text" as const, text: t }], details };
|
|
11
9
|
}
|
|
12
10
|
|
|
11
|
+
type ApprovalContext = {
|
|
12
|
+
hasUI: boolean;
|
|
13
|
+
ui: { confirm(title: string, message: string): Promise<boolean> };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function approvePackageOperation(
|
|
17
|
+
operation: PackageOperation,
|
|
18
|
+
command: string,
|
|
19
|
+
natives: Pick<Natives, "security">,
|
|
20
|
+
ctx: ApprovalContext,
|
|
21
|
+
): Promise<{ allowed: boolean; approved: boolean; message?: string }> {
|
|
22
|
+
const settings = await natives.security();
|
|
23
|
+
const decision = packagePermissionDecision(settings, operation);
|
|
24
|
+
if (!decision.approvalRequired) return { allowed: true, approved: false };
|
|
25
|
+
if (!ctx.hasUI) {
|
|
26
|
+
return {
|
|
27
|
+
allowed: false,
|
|
28
|
+
approved: false,
|
|
29
|
+
message: `${operation} requires interactive approval; change mutationApproval in /packed only to deliberately opt out.`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const approved = await ctx.ui.confirm(
|
|
33
|
+
`${operation[0]!.toUpperCase()}${operation.slice(1)} Pi package`,
|
|
34
|
+
`Run: ${command}\n\nThis operation can execute package code or mutate Pi settings/install roots. Continue?`,
|
|
35
|
+
);
|
|
36
|
+
return approved ? { allowed: true, approved: true } : { allowed: false, approved: false, message: `${operation} cancelled by user.` };
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
export async function installPackageWithPolicy(
|
|
14
40
|
source: string,
|
|
15
41
|
natives: Pick<Natives, "security" | "install">,
|
|
16
|
-
ctx:
|
|
42
|
+
ctx: ApprovalContext,
|
|
17
43
|
) {
|
|
18
44
|
try {
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
|
|
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);
|
|
45
|
+
const approval = await approvePackageOperation("install", `pi install ${source}`, natives, ctx);
|
|
46
|
+
if (!approval.allowed) return text(approval.message ?? "install denied");
|
|
47
|
+
const out = await natives.install(source, approval.approved);
|
|
29
48
|
return text(out || `Installed ${source}. Reload with /reload to activate.`);
|
|
30
49
|
} catch (error) {
|
|
31
50
|
return text(`install failed: ${error instanceof Error ? error.message : error}`);
|
|
32
51
|
}
|
|
33
52
|
}
|
|
34
53
|
|
|
54
|
+
export async function updatePackageWithPolicy(
|
|
55
|
+
source: string,
|
|
56
|
+
natives: Pick<Natives, "security" | "update">,
|
|
57
|
+
ctx: ApprovalContext,
|
|
58
|
+
) {
|
|
59
|
+
try {
|
|
60
|
+
const approval = await approvePackageOperation("update", `pi update --extension ${source}`, natives, ctx);
|
|
61
|
+
if (!approval.allowed) return text(approval.message ?? "update denied");
|
|
62
|
+
const out = await natives.update(source, approval.approved);
|
|
63
|
+
return text(`${out || `Updated ${source}.`} Reload with /reload to activate.`);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return text(`update failed: ${error instanceof Error ? error.message : error}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function removePackageWithPolicy(
|
|
70
|
+
name: string,
|
|
71
|
+
natives: Pick<Natives, "security" | "remove">,
|
|
72
|
+
ctx: ApprovalContext,
|
|
73
|
+
) {
|
|
74
|
+
try {
|
|
75
|
+
const approval = await approvePackageOperation("remove", `pi remove npm:${name}`, natives, ctx);
|
|
76
|
+
if (!approval.allowed) return text(approval.message ?? "remove denied");
|
|
77
|
+
const out = await natives.remove(name, approval.approved);
|
|
78
|
+
return text(out || `Removed ${name}. Reload with /reload to deactivate.`);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return text(`remove failed: ${error instanceof Error ? error.message : error}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
35
84
|
export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
36
85
|
pi.registerTool({
|
|
37
86
|
name: "pkg_search",
|
|
38
87
|
label: "Pi Package Search",
|
|
39
|
-
description:
|
|
40
|
-
"Search Pi packages (extensions, skills, themes, prompts) on the npm registry. " +
|
|
41
|
-
"Scoped to the pi-package keyword automatically. Returns name, version, description.",
|
|
88
|
+
description: "Search Pi packages on npm. Bounded read operation; defaults to 10 results and caps at 50.",
|
|
42
89
|
parameters: Type.Object({
|
|
43
90
|
query: Type.String({ description: "Search terms, e.g. 'lsp' or 'telegram'" }),
|
|
44
91
|
limit: Type.Optional(Type.Number({ description: "Max results (default 10, max 50)" })),
|
|
45
92
|
}),
|
|
46
|
-
async execute(_id, params
|
|
93
|
+
async execute(_id, params) {
|
|
47
94
|
try {
|
|
48
95
|
const r = await natives.search(params.query, params.limit ?? 10);
|
|
49
96
|
if (r.results.length === 0) return text(`No Pi packages found for "${params.query}".`);
|
|
50
|
-
const lines = r.results.map(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return text(
|
|
54
|
-
`Found ${r.total} pi package(s) (showing ${r.results.length}):\n\n${lines.join("\n")}`,
|
|
55
|
-
{ results: r.results, total: r.total },
|
|
56
|
-
);
|
|
57
|
-
} catch (e) {
|
|
58
|
-
return text(`pkg_search failed: ${e instanceof Error ? e.message : e}`);
|
|
97
|
+
const lines = r.results.map((p, i) => `${i + 1}. ${p.name}@${p.version}\n ${p.description ?? ""}`);
|
|
98
|
+
return text(`Found ${r.total} pi package(s) (showing ${r.results.length}):\n\n${lines.join("\n")}`, { results: r.results, total: r.total });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return text(`pkg_search failed: ${error instanceof Error ? error.message : error}`);
|
|
59
101
|
}
|
|
60
102
|
},
|
|
61
103
|
});
|
|
@@ -63,13 +105,9 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
63
105
|
pi.registerTool({
|
|
64
106
|
name: "pkg_info",
|
|
65
107
|
label: "Pi Package Info",
|
|
66
|
-
description:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
parameters: Type.Object({
|
|
70
|
-
name: Type.String({ description: "npm package name, e.g. 'pi-lsp' or '@scope/pkg'" }),
|
|
71
|
-
}),
|
|
72
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
108
|
+
description: "Show bounded metadata and declared Pi resources for one package.",
|
|
109
|
+
parameters: Type.Object({ name: Type.String({ description: "npm package name" }) }),
|
|
110
|
+
async execute(_id, params) {
|
|
73
111
|
try {
|
|
74
112
|
const info = await natives.info(params.name);
|
|
75
113
|
const lines = [
|
|
@@ -82,8 +120,8 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
82
120
|
info.modified ? `modified: ${info.modified}` : "",
|
|
83
121
|
].filter(Boolean);
|
|
84
122
|
return text(lines.join("\n"), { info });
|
|
85
|
-
} catch (
|
|
86
|
-
return text(`pkg_info failed: ${
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return text(`pkg_info failed: ${error instanceof Error ? error.message : error}`);
|
|
87
125
|
}
|
|
88
126
|
},
|
|
89
127
|
});
|
|
@@ -91,14 +129,30 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
91
129
|
pi.registerTool({
|
|
92
130
|
name: "pkg_install",
|
|
93
131
|
label: "Pi Package Install",
|
|
94
|
-
description:
|
|
95
|
-
|
|
96
|
-
"or https:// URLs. Packages execute arbitrary code. Approval follows the installApproval policy configured in /packed (secure default: always).",
|
|
97
|
-
parameters: Type.Object({
|
|
98
|
-
source: Type.String({ description: "e.g. 'npm:pi-lsp', 'npm:@scope/pkg@1.2.3', 'git:github.com/u/r@v1'" }),
|
|
99
|
-
}),
|
|
132
|
+
description: "Install a Pi package through the authenticated daemon. Operation-aware approval is secure by default.",
|
|
133
|
+
parameters: Type.Object({ source: Type.String({ description: "npm:, git:, or https source" }) }),
|
|
100
134
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
101
135
|
return installPackageWithPolicy(params.source, natives, ctx);
|
|
102
136
|
},
|
|
103
137
|
});
|
|
138
|
+
|
|
139
|
+
pi.registerTool({
|
|
140
|
+
name: "pkg_update",
|
|
141
|
+
label: "Pi Package Update",
|
|
142
|
+
description: "Update one configured Pi package through Pi's documented update command. Operation-aware approval is secure by default.",
|
|
143
|
+
parameters: Type.Object({ source: Type.String({ description: "configured npm:, git:, or https source" }) }),
|
|
144
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
145
|
+
return updatePackageWithPolicy(params.source, natives, ctx);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
pi.registerTool({
|
|
150
|
+
name: "pkg_remove",
|
|
151
|
+
label: "Pi Package Remove",
|
|
152
|
+
description: "Remove an installed npm Pi package through the authenticated daemon. Operation-aware approval is secure by default.",
|
|
153
|
+
parameters: Type.Object({ name: Type.String({ description: "bare npm name, e.g. pi-lsp or @scope/pkg" }) }),
|
|
154
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
155
|
+
return removePackageWithPolicy(params.name, natives, ctx);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
104
158
|
}
|
package/extension/src/tui.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earend
|
|
|
10
10
|
import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
|
|
11
11
|
import type { Row, ViewMode } from "./model.js";
|
|
12
12
|
import type { Natives } from "./packed.js";
|
|
13
|
+
import { approvePackageOperation } from "./tools.js";
|
|
13
14
|
|
|
14
15
|
interface PanelAction {
|
|
15
16
|
type: "menu" | "refresh";
|
|
@@ -64,25 +65,32 @@ export async function showPackages(ctx: ExtensionCommandContext, natives: Native
|
|
|
64
65
|
);
|
|
65
66
|
|
|
66
67
|
if (choice?.startsWith("Update")) {
|
|
67
|
-
ctx.ui.notify(`Updating ${row.name}…`, "info");
|
|
68
68
|
try {
|
|
69
|
-
await
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
const approval = await approvePackageOperation("update", `pi update --extension npm:${row.name}`, natives, ctx);
|
|
70
|
+
if (!approval.allowed) {
|
|
71
|
+
ctx.ui.notify(approval.message ?? "update denied", "warning");
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
ctx.ui.notify(`Updating ${row.name}…`, "info");
|
|
75
|
+
await natives.update(`npm:${row.name}`, approval.approved);
|
|
76
|
+
ctx.ui.notify(`Updated ${row.name} to ${row.latest}; reloading Pi resources`, "info");
|
|
77
|
+
await ctx.reload();
|
|
78
|
+
return;
|
|
73
79
|
} catch (e) {
|
|
74
80
|
ctx.ui.notify(`update failed: ${e instanceof Error ? e.message : e}`, "error");
|
|
75
81
|
}
|
|
76
82
|
} else if (choice === "Remove") {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await natives.remove(row.name);
|
|
83
|
+
try {
|
|
84
|
+
const approval = await approvePackageOperation("remove", `pi remove npm:${row.name}`, natives, ctx);
|
|
85
|
+
if (approval.allowed) {
|
|
86
|
+
await natives.remove(row.name, approval.approved);
|
|
81
87
|
ctx.ui.notify(`Removed ${row.name}`, "info");
|
|
82
88
|
rows = rows.filter((r) => r.name !== row.name);
|
|
83
|
-
}
|
|
84
|
-
ctx.ui.notify(
|
|
89
|
+
} else {
|
|
90
|
+
ctx.ui.notify(approval.message ?? "remove denied", "warning");
|
|
85
91
|
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
|
|
86
94
|
}
|
|
87
95
|
}
|
|
88
96
|
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -11,15 +11,21 @@ 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
|
|
14
|
+
import {
|
|
15
|
+
assertPackagePermission,
|
|
16
|
+
type MutationApproval,
|
|
17
|
+
type PackageOperation,
|
|
18
|
+
type SecuritySettingsPort,
|
|
19
|
+
} from "./security.ts";
|
|
15
20
|
|
|
16
21
|
function defaultPiBinForUnit(): string | undefined {
|
|
17
22
|
const b = defaultPiBin();
|
|
18
23
|
return b === "pi" ? undefined : b; // bare name needs no pin
|
|
19
24
|
}
|
|
20
25
|
import {
|
|
21
|
-
|
|
26
|
+
SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
|
|
22
27
|
} from "./constants.ts";
|
|
28
|
+
import { VERSION } from "./version.ts";
|
|
23
29
|
|
|
24
30
|
const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
|
|
25
31
|
|
|
@@ -29,12 +35,13 @@ usage:
|
|
|
29
35
|
packed search <query> [--offline] [--json] search npm (or the local mirror with --offline)
|
|
30
36
|
packed info <name> [--json] package details
|
|
31
37
|
packed updates [--json] updates per the local mirror
|
|
38
|
+
packed update <source> [--approve] [--json] update one configured package through Pi
|
|
32
39
|
packed mirror [--json] sync upstream into the local SQLite index
|
|
33
40
|
packed installed [--json] installed pi packages
|
|
34
41
|
packed catalog [--json] local package index (apt-cache stats)
|
|
35
|
-
packed install <source> [--json]
|
|
36
|
-
packed remove <name> [--json]
|
|
37
|
-
packed security [always|never] [--json]
|
|
42
|
+
packed install <source> [--approve] [--json] pi install npm:|git:|https://… via daemon
|
|
43
|
+
packed remove <name> [--approve] [--json] remove by bare npm name via daemon
|
|
44
|
+
packed security [always|never] [--approve] [--json] read or set mutation approval policy
|
|
38
45
|
packed serve run the long-running daemon
|
|
39
46
|
packed service print a systemd user unit
|
|
40
47
|
packed version print version
|
|
@@ -61,16 +68,18 @@ interface Flags {
|
|
|
61
68
|
limit: number;
|
|
62
69
|
cached: boolean;
|
|
63
70
|
offline: boolean;
|
|
71
|
+
approved: boolean;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
|
|
67
|
-
const flags: Flags = { json: false, limit: SEARCH_DEFAULT_LIMIT, cached: false, offline: false };
|
|
75
|
+
const flags: Flags = { json: false, limit: SEARCH_DEFAULT_LIMIT, cached: false, offline: false, approved: false };
|
|
68
76
|
const pos: string[] = [];
|
|
69
77
|
for (let i = 0; i < rest.length; i++) {
|
|
70
78
|
const a = rest[i]!;
|
|
71
79
|
if (a === "--json") flags.json = true;
|
|
72
80
|
else if (a === "--cached") flags.cached = true;
|
|
73
81
|
else if (a === "--offline") flags.offline = true;
|
|
82
|
+
else if (a === "--approve") flags.approved = true;
|
|
74
83
|
else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
|
|
75
84
|
else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
|
|
76
85
|
else pos.push(a);
|
|
@@ -84,6 +93,18 @@ const ok = (out: string): CliResult => ({ code: 0, out });
|
|
|
84
93
|
const fail = (out: string, code = 1): CliResult => ({ code, out });
|
|
85
94
|
const usageErr = (out: string): CliResult => ({ code: 2, out });
|
|
86
95
|
|
|
96
|
+
const PACKAGE_COMMAND_OPERATIONS: Record<string, PackageOperation | undefined> = {
|
|
97
|
+
search: "search",
|
|
98
|
+
info: "info",
|
|
99
|
+
installed: "installed",
|
|
100
|
+
catalog: "catalog",
|
|
101
|
+
updates: "updates",
|
|
102
|
+
update: "update",
|
|
103
|
+
mirror: "mirror",
|
|
104
|
+
install: "install",
|
|
105
|
+
remove: "remove",
|
|
106
|
+
};
|
|
107
|
+
|
|
87
108
|
const commands: Record<string, { usage: string; run: Command }> = {
|
|
88
109
|
search: {
|
|
89
110
|
usage: "packed search <query> [--offline] [--limit N] [--json]",
|
|
@@ -193,7 +214,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
193
214
|
const source = pos[0] ?? "";
|
|
194
215
|
if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["install"]!.usage}\n`);
|
|
195
216
|
try {
|
|
196
|
-
const output = await d.inst.install(source);
|
|
217
|
+
const output = await d.inst.install(source, { approved: flags.approved });
|
|
197
218
|
return flags.json ? ok(`${JSON.stringify({ ok: true, source, output })}\n`) : ok(`${output}\n`);
|
|
198
219
|
} catch (e) {
|
|
199
220
|
const error = e instanceof Error ? e.message : String(e);
|
|
@@ -210,21 +231,40 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
210
231
|
return usageErr(`usage: ${commands["security"]!.usage}\n`);
|
|
211
232
|
}
|
|
212
233
|
const settings = requested
|
|
213
|
-
? await d.security.
|
|
234
|
+
? await d.security.setMutationApproval(requested as MutationApproval, { approved: flags.approved })
|
|
214
235
|
: await d.security.security();
|
|
215
236
|
return flags.json
|
|
216
237
|
? ok(`${JSON.stringify(settings)}\n`)
|
|
217
|
-
: ok(`
|
|
238
|
+
: ok(`package mutation approval: ${settings.mutationApproval}\n`);
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
update: {
|
|
243
|
+
usage: "packed update <configured-source> [--approve] [--json]",
|
|
244
|
+
async run(_rest, d, flags, pos) {
|
|
245
|
+
const source = pos[0] ?? "";
|
|
246
|
+
if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["update"]!.usage}\n`);
|
|
247
|
+
try {
|
|
248
|
+
const output = await d.inst.update(source, { approved: flags.approved });
|
|
249
|
+
return flags.json
|
|
250
|
+
? ok(`${JSON.stringify({ ok: true, source, output, reloadRequired: true })}\n`)
|
|
251
|
+
: ok(`${output}\nReload Pi with /reload to activate the updated package.\n`);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
254
|
+
return flags.json
|
|
255
|
+
? fail(`${JSON.stringify({ ok: false, source, error: message, reloadRequired: false })}\n`)
|
|
256
|
+
: fail(`${message}\n`);
|
|
257
|
+
}
|
|
218
258
|
},
|
|
219
259
|
},
|
|
220
260
|
|
|
221
261
|
remove: {
|
|
222
|
-
usage: "packed remove <name> [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
|
|
262
|
+
usage: "packed remove <name> [--approve] [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
|
|
223
263
|
async run(_rest, d, flags, pos) {
|
|
224
264
|
const name = pos[0] ?? "";
|
|
225
265
|
if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["remove"]!.usage}\n`);
|
|
226
266
|
try {
|
|
227
|
-
const output = await d.inst.remove(`npm:${name}
|
|
267
|
+
const output = await d.inst.remove(`npm:${name}`, { approved: flags.approved });
|
|
228
268
|
return flags.json ? ok(`${JSON.stringify({ ok: true, name, output })}\n`) : ok(`${output}\n`);
|
|
229
269
|
} catch (e) {
|
|
230
270
|
const error = e instanceof Error ? e.message : String(e);
|
|
@@ -280,6 +320,15 @@ export async function cliRun(args: string[], d: CliDeps): Promise<CliResult> {
|
|
|
280
320
|
if (!cmd) return usageErr(`unknown command "${name}"\n${USAGE}`);
|
|
281
321
|
const { flags, pos } = parseFlags(rest);
|
|
282
322
|
try {
|
|
323
|
+
const validMutationInput = name === "install" || name === "update"
|
|
324
|
+
? SOURCE_RE.test(pos[0] ?? "")
|
|
325
|
+
: name === "remove" ? NAME_RE.test(pos[0] ?? "")
|
|
326
|
+
: name === "security" ? (pos[0] === undefined || pos[0] === "always" || pos[0] === "never")
|
|
327
|
+
: true;
|
|
328
|
+
const operation = name === "security"
|
|
329
|
+
? (pos[0] === undefined ? "security.read" : "security.write")
|
|
330
|
+
: PACKAGE_COMMAND_OPERATIONS[name];
|
|
331
|
+
if (operation && validMutationInput) assertPackagePermission(await d.security.security(), operation, flags.approved);
|
|
283
332
|
return await cmd.run(rest, d, flags, pos);
|
|
284
333
|
} catch (e) {
|
|
285
334
|
return fail(`${name} failed: ${e instanceof Error ? e.message : e}\n`);
|
package/src/client.ts
CHANGED
|
@@ -8,7 +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 {
|
|
11
|
+
import type { MutationApproval, SecuritySettings } from "./security.ts";
|
|
12
12
|
|
|
13
13
|
export type FetchTransport = (request: Request) => Promise<Response>;
|
|
14
14
|
|
|
@@ -18,9 +18,10 @@ export interface PackageDaemonPort {
|
|
|
18
18
|
installed(): Promise<InstalledPkg[]>;
|
|
19
19
|
updates(): Promise<UpdateEntry[]>;
|
|
20
20
|
security(): Promise<SecuritySettings>;
|
|
21
|
-
|
|
22
|
-
install(source: string): Promise<string>;
|
|
23
|
-
remove(name: string): Promise<string>;
|
|
21
|
+
setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
|
|
22
|
+
install(source: string, approved?: boolean): Promise<string>;
|
|
23
|
+
remove(name: string, approved?: boolean): Promise<string>;
|
|
24
|
+
update(source: string, approved?: boolean): Promise<string>;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
interface MutationResponse {
|
|
@@ -92,41 +93,54 @@ export class PackageDaemonClient implements PackageDaemonPort {
|
|
|
92
93
|
return this.request("/security");
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
return this.request("/security", { method: "POST", body: JSON.stringify({
|
|
96
|
+
setMutationApproval(mutationApproval: MutationApproval, approved = false): Promise<SecuritySettings> {
|
|
97
|
+
return this.request("/security", { method: "POST", body: JSON.stringify({ mutationApproval, approved }) });
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
async install(source: string): Promise<string> {
|
|
100
|
+
async install(source: string, approved = false): Promise<string> {
|
|
100
101
|
const result = await this.request<MutationResponse>("/install", {
|
|
101
102
|
method: "POST",
|
|
102
|
-
body: JSON.stringify({ source }),
|
|
103
|
+
body: JSON.stringify({ source, approved }),
|
|
103
104
|
});
|
|
104
105
|
if (!result.ok) throw new PackageDaemonError(result.output || `failed to install ${source}`, "install");
|
|
105
106
|
return result.output;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
async remove(name: string): Promise<string> {
|
|
109
|
+
async remove(name: string, approved = false): Promise<string> {
|
|
109
110
|
const result = await this.request<MutationResponse>("/remove", {
|
|
110
111
|
method: "POST",
|
|
111
|
-
body: JSON.stringify({ name }),
|
|
112
|
+
body: JSON.stringify({ name, approved }),
|
|
112
113
|
});
|
|
113
114
|
if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "remove");
|
|
114
115
|
return result.output;
|
|
115
116
|
}
|
|
117
|
+
|
|
118
|
+
async update(source: string, approved = false): Promise<string> {
|
|
119
|
+
const result = await this.request<MutationResponse>("/update", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: JSON.stringify({ source, approved }),
|
|
122
|
+
});
|
|
123
|
+
if (!result.ok) throw new PackageDaemonError(result.output || `failed to update ${source}`, "update");
|
|
124
|
+
return result.output;
|
|
125
|
+
}
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
export class PackageDaemonInstaller implements Installer {
|
|
119
129
|
constructor(private readonly client: PackageDaemonClient) {}
|
|
120
130
|
|
|
121
|
-
install(source: string): Promise<string> {
|
|
122
|
-
return this.client.install(source);
|
|
131
|
+
install(source: string, options?: { approved?: boolean }): Promise<string> {
|
|
132
|
+
return this.client.install(source, options?.approved);
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
remove(source: string): Promise<string> {
|
|
135
|
+
remove(source: string, options?: { approved?: boolean }): Promise<string> {
|
|
126
136
|
if (!source.startsWith("npm:") || source.length <= 4) {
|
|
127
137
|
throw new PackageDaemonError("daemon package removal requires an npm: source", "remove");
|
|
128
138
|
}
|
|
129
|
-
return this.client.remove(source.slice(4));
|
|
139
|
+
return this.client.remove(source.slice(4), options?.approved);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
update(source: string, options?: { approved?: boolean }): Promise<string> {
|
|
143
|
+
return this.client.update(source, options?.approved);
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
146
|
|
|
@@ -135,20 +149,24 @@ export class DaemonBackedSecurity {
|
|
|
135
149
|
async security(): Promise<SecuritySettings> {
|
|
136
150
|
return (await connectPackageDaemon(this.stateDirectory)).security();
|
|
137
151
|
}
|
|
138
|
-
async
|
|
139
|
-
return (await connectPackageDaemon(this.stateDirectory)).
|
|
152
|
+
async setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings> {
|
|
153
|
+
return (await connectPackageDaemon(this.stateDirectory)).setMutationApproval(value, options?.approved);
|
|
140
154
|
}
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
export class DaemonBackedInstaller implements Installer {
|
|
144
158
|
constructor(private readonly stateDirectory: string) {}
|
|
145
159
|
|
|
146
|
-
async install(source: string): Promise<string> {
|
|
147
|
-
return (await connectPackageDaemon(this.stateDirectory)).install(source);
|
|
160
|
+
async install(source: string, options?: { approved?: boolean }): Promise<string> {
|
|
161
|
+
return (await connectPackageDaemon(this.stateDirectory)).install(source, options?.approved);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async remove(source: string, options?: { approved?: boolean }): Promise<string> {
|
|
165
|
+
return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source, options);
|
|
148
166
|
}
|
|
149
167
|
|
|
150
|
-
async
|
|
151
|
-
return
|
|
168
|
+
async update(source: string, options?: { approved?: boolean }): Promise<string> {
|
|
169
|
+
return (await connectPackageDaemon(this.stateDirectory)).update(source, options?.approved);
|
|
152
170
|
}
|
|
153
171
|
}
|
|
154
172
|
|
package/src/constants.ts
CHANGED
package/src/install.ts
CHANGED
|
@@ -23,11 +23,15 @@ export class ExecInstaller implements Installer {
|
|
|
23
23
|
return out;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
install(source: string): Promise<string> {
|
|
26
|
+
install(source: string, _options?: { approved?: boolean }): Promise<string> {
|
|
27
27
|
return this.run(["install", source]);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
remove(source: string): Promise<string> {
|
|
30
|
+
remove(source: string, _options?: { approved?: boolean }): Promise<string> {
|
|
31
31
|
return this.run(["remove", source]);
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
update(source: string, _options?: { approved?: boolean }): Promise<string> {
|
|
35
|
+
return this.run(["update", "--extension", source]);
|
|
36
|
+
}
|
|
33
37
|
}
|
package/src/ports.ts
CHANGED
|
@@ -39,8 +39,9 @@ export interface Registry {
|
|
|
39
39
|
|
|
40
40
|
/** Driven port: pi CLI mutations. */
|
|
41
41
|
export interface Installer {
|
|
42
|
-
install(source: string): Promise<string>;
|
|
43
|
-
remove(source: string): Promise<string>;
|
|
42
|
+
install(source: string, options?: { approved?: boolean }): Promise<string>;
|
|
43
|
+
remove(source: string, options?: { approved?: boolean }): Promise<string>;
|
|
44
|
+
update(source: string, options?: { approved?: boolean }): Promise<string>;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export interface InstalledPkg {
|
package/src/security.ts
CHANGED
|
@@ -2,21 +2,82 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { SECURITY_FILE } from "./constants.ts";
|
|
4
4
|
|
|
5
|
-
export const
|
|
6
|
-
export type
|
|
7
|
-
|
|
5
|
+
export const MUTATION_APPROVAL_VALUES = ["always", "never"] as const;
|
|
6
|
+
export type MutationApproval = typeof MUTATION_APPROVAL_VALUES[number];
|
|
7
|
+
|
|
8
|
+
export const PACKAGE_OPERATIONS = [
|
|
9
|
+
"search",
|
|
10
|
+
"info",
|
|
11
|
+
"installed",
|
|
12
|
+
"catalog",
|
|
13
|
+
"updates",
|
|
14
|
+
"security.read",
|
|
15
|
+
"mirror",
|
|
16
|
+
"install",
|
|
17
|
+
"update",
|
|
18
|
+
"remove",
|
|
19
|
+
"security.write",
|
|
20
|
+
] as const;
|
|
21
|
+
export type PackageOperation = typeof PACKAGE_OPERATIONS[number];
|
|
22
|
+
export type PackageOperationClassification = "read" | "maintenance" | "code-execution" | "settings-mutation" | "security-mutation";
|
|
23
|
+
|
|
24
|
+
export interface SecuritySettings { mutationApproval: MutationApproval }
|
|
8
25
|
export interface SecuritySettingsPort {
|
|
9
26
|
security(): Promise<SecuritySettings>;
|
|
10
|
-
|
|
27
|
+
setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PackagePermissionDecision {
|
|
31
|
+
operation: PackageOperation;
|
|
32
|
+
classification: PackageOperationClassification;
|
|
33
|
+
approvalRequired: boolean;
|
|
11
34
|
}
|
|
12
35
|
|
|
13
|
-
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
|
|
36
|
+
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { mutationApproval: "always" };
|
|
37
|
+
|
|
38
|
+
const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification> = {
|
|
39
|
+
search: "read",
|
|
40
|
+
info: "read",
|
|
41
|
+
installed: "read",
|
|
42
|
+
catalog: "read",
|
|
43
|
+
updates: "read",
|
|
44
|
+
"security.read": "read",
|
|
45
|
+
mirror: "maintenance",
|
|
46
|
+
install: "code-execution",
|
|
47
|
+
update: "code-execution",
|
|
48
|
+
remove: "settings-mutation",
|
|
49
|
+
"security.write": "security-mutation",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export class PackageApprovalRequiredError extends Error {
|
|
53
|
+
readonly code = "approval_required";
|
|
54
|
+
constructor(readonly operation: PackageOperation) {
|
|
55
|
+
super(`approval required for package operation ${operation}`);
|
|
56
|
+
this.name = "PackageApprovalRequiredError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function packagePermissionDecision(settings: SecuritySettings, operation: PackageOperation): PackagePermissionDecision {
|
|
61
|
+
const classification = CLASSIFICATIONS[operation];
|
|
62
|
+
const guarded = classification === "code-execution" || classification === "settings-mutation" || classification === "security-mutation";
|
|
63
|
+
return { operation, classification, approvalRequired: guarded && settings.mutationApproval === "always" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function assertPackagePermission(settings: SecuritySettings, operation: PackageOperation, approved = false): void {
|
|
67
|
+
if (packagePermissionDecision(settings, operation).approvalRequired && !approved) {
|
|
68
|
+
throw new PackageApprovalRequiredError(operation);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
14
71
|
|
|
15
72
|
export function readSecuritySettings(stateDir: string): SecuritySettings {
|
|
16
73
|
try {
|
|
17
|
-
const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as {
|
|
18
|
-
|
|
19
|
-
|
|
74
|
+
const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as {
|
|
75
|
+
mutationApproval?: unknown;
|
|
76
|
+
installApproval?: unknown;
|
|
77
|
+
};
|
|
78
|
+
const stored = value.mutationApproval ?? value.installApproval;
|
|
79
|
+
return MUTATION_APPROVAL_VALUES.includes(stored as MutationApproval)
|
|
80
|
+
? { mutationApproval: stored as MutationApproval }
|
|
20
81
|
: { ...DEFAULT_SECURITY_SETTINGS };
|
|
21
82
|
} catch {
|
|
22
83
|
return { ...DEFAULT_SECURITY_SETTINGS };
|
|
@@ -24,7 +85,7 @@ export function readSecuritySettings(stateDir: string): SecuritySettings {
|
|
|
24
85
|
}
|
|
25
86
|
|
|
26
87
|
export function writeSecuritySettings(stateDir: string, settings: SecuritySettings): SecuritySettings {
|
|
27
|
-
if (!
|
|
88
|
+
if (!MUTATION_APPROVAL_VALUES.includes(settings.mutationApproval)) throw new Error("mutationApproval must be always or never");
|
|
28
89
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
29
90
|
const target = join(stateDir, SECURITY_FILE);
|
|
30
91
|
const temporary = `${target}.tmp`;
|
package/src/service.ts
CHANGED
|
@@ -9,9 +9,17 @@ import { TTLCache } from "./cache.ts";
|
|
|
9
9
|
import { loadUpdates } from "./watcher.ts";
|
|
10
10
|
import { readInstalledPackages, defaultPiHome } from "./installed.ts";
|
|
11
11
|
import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
|
|
13
|
+
import { VERSION } from "./version.ts";
|
|
13
14
|
import { createLogger } from "./log.ts";
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
assertPackagePermission,
|
|
17
|
+
PackageApprovalRequiredError,
|
|
18
|
+
readSecuritySettings,
|
|
19
|
+
writeSecuritySettings,
|
|
20
|
+
type MutationApproval,
|
|
21
|
+
type PackageOperation,
|
|
22
|
+
} from "./security.ts";
|
|
15
23
|
|
|
16
24
|
const log = createLogger("service");
|
|
17
25
|
|
|
@@ -31,13 +39,25 @@ function json(v: unknown, init?: ResponseInit): Response {
|
|
|
31
39
|
return Response.json(v, init);
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
function err(status: number, msg: string): Response {
|
|
35
|
-
return json({ error: msg }, { status });
|
|
42
|
+
function err(status: number, msg: string, details: Record<string, unknown> = {}): Response {
|
|
43
|
+
return json({ error: msg, ...details }, { status });
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
|
|
39
47
|
const cache = deps.cache ?? new TTLCache();
|
|
40
48
|
|
|
49
|
+
function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
|
|
50
|
+
try {
|
|
51
|
+
assertPackagePermission(readSecuritySettings(deps.stateDir), operation, approved);
|
|
52
|
+
return undefined;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error instanceof PackageApprovalRequiredError) {
|
|
55
|
+
return err(403, error.message, { code: error.code, operation: error.operation });
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
async function route(req: Request): Promise<Response> {
|
|
42
62
|
const url = new URL(req.url);
|
|
43
63
|
const path = url.pathname;
|
|
@@ -51,16 +71,18 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
if (path === "/security" && req.method === "POST") {
|
|
54
|
-
let
|
|
74
|
+
let body: { mutationApproval?: unknown; approved?: unknown };
|
|
55
75
|
try {
|
|
56
|
-
|
|
76
|
+
body = (await req.json()) as typeof body;
|
|
57
77
|
} catch {
|
|
58
78
|
return err(400, "invalid security settings JSON");
|
|
59
79
|
}
|
|
60
|
-
if (
|
|
61
|
-
return err(400, "
|
|
80
|
+
if (body.mutationApproval !== "always" && body.mutationApproval !== "never") {
|
|
81
|
+
return err(400, "mutationApproval must be always or never");
|
|
62
82
|
}
|
|
63
|
-
|
|
83
|
+
const denied = authorize("security.write", body.approved === true);
|
|
84
|
+
if (denied) return denied;
|
|
85
|
+
return json(writeSecuritySettings(deps.stateDir, { mutationApproval: body.mutationApproval as MutationApproval }));
|
|
64
86
|
}
|
|
65
87
|
|
|
66
88
|
if (path === "/search" && req.method === "GET") {
|
|
@@ -100,17 +122,21 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
100
122
|
|
|
101
123
|
if (path === "/remove" && req.method === "POST") {
|
|
102
124
|
let name = "";
|
|
125
|
+
let approved = false;
|
|
103
126
|
try {
|
|
104
|
-
const body = (await req.json()) as { name?: unknown };
|
|
127
|
+
const body = (await req.json()) as { name?: unknown; approved?: unknown };
|
|
105
128
|
name = String(body.name ?? "");
|
|
129
|
+
approved = body.approved === true;
|
|
106
130
|
} catch {
|
|
107
131
|
/* fall through to validation */
|
|
108
132
|
}
|
|
109
133
|
if (!NAME_RE.test(name)) {
|
|
110
134
|
return err(400, "invalid name; want a bare npm package name");
|
|
111
135
|
}
|
|
136
|
+
const denied = authorize("remove", approved);
|
|
137
|
+
if (denied) return denied;
|
|
112
138
|
try {
|
|
113
|
-
const output = await deps.inst.remove(`npm:${name}
|
|
139
|
+
const output = await deps.inst.remove(`npm:${name}`, { approved });
|
|
114
140
|
return json({ ok: true, name, output });
|
|
115
141
|
} catch (e) {
|
|
116
142
|
return json({ ok: false, name, output: e instanceof Error ? e.message : String(e) });
|
|
@@ -119,23 +145,50 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
119
145
|
|
|
120
146
|
if (path === "/install" && req.method === "POST") {
|
|
121
147
|
let source = "";
|
|
148
|
+
let approved = false;
|
|
122
149
|
try {
|
|
123
|
-
const body = (await req.json()) as { source?: unknown };
|
|
150
|
+
const body = (await req.json()) as { source?: unknown; approved?: unknown };
|
|
124
151
|
source = String(body.source ?? "");
|
|
152
|
+
approved = body.approved === true;
|
|
125
153
|
} catch {
|
|
126
154
|
/* fall through to validation */
|
|
127
155
|
}
|
|
128
156
|
if (!SOURCE_RE.test(source)) {
|
|
129
157
|
return err(400, "invalid source; want npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], or https://…");
|
|
130
158
|
}
|
|
159
|
+
const denied = authorize("install", approved);
|
|
160
|
+
if (denied) return denied;
|
|
131
161
|
try {
|
|
132
|
-
const output = await deps.inst.install(source);
|
|
162
|
+
const output = await deps.inst.install(source, { approved });
|
|
133
163
|
return json({ ok: true, source, output });
|
|
134
164
|
} catch (e) {
|
|
135
165
|
return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
|
|
136
166
|
}
|
|
137
167
|
}
|
|
138
168
|
|
|
169
|
+
if (path === "/update" && req.method === "POST") {
|
|
170
|
+
let source = "";
|
|
171
|
+
let approved = false;
|
|
172
|
+
try {
|
|
173
|
+
const body = (await req.json()) as { source?: unknown; approved?: unknown };
|
|
174
|
+
source = String(body.source ?? "");
|
|
175
|
+
approved = body.approved === true;
|
|
176
|
+
} catch {
|
|
177
|
+
/* fall through to validation */
|
|
178
|
+
}
|
|
179
|
+
if (!SOURCE_RE.test(source)) {
|
|
180
|
+
return err(400, "invalid source; want a configured npm:, git:, or https package source");
|
|
181
|
+
}
|
|
182
|
+
const denied = authorize("update", approved);
|
|
183
|
+
if (denied) return denied;
|
|
184
|
+
try {
|
|
185
|
+
const output = await deps.inst.update(source, { approved });
|
|
186
|
+
return json({ ok: true, source, output, reloadRequired: true });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return json({ ok: false, source, output: error instanceof Error ? error.message : String(error), reloadRequired: false });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
139
192
|
if (path === "/updates" && req.method === "GET") {
|
|
140
193
|
const snap = await loadUpdates(deps.stateDir);
|
|
141
194
|
return json(snap ?? { updates: [] });
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
function packageVersion(): string {
|
|
4
|
+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as unknown;
|
|
5
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
6
|
+
throw new Error("pi-packed package manifest must be an object");
|
|
7
|
+
}
|
|
8
|
+
const version = (manifest as Record<string, unknown>)["version"];
|
|
9
|
+
if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
10
|
+
throw new Error("pi-packed package manifest has an invalid version");
|
|
11
|
+
}
|
|
12
|
+
return version;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Runtime package version; package.json is the single release source of truth. */
|
|
16
|
+
export const VERSION = packageVersion();
|