pi-openappa 0.3.0 → 0.3.1
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 +66 -27
- package/extensions/index.ts +117 -38
- package/package.json +1 -1
- package/src/gate.ts +70 -18
- package/src/hook-client.ts +6 -1
- package/src/installer.ts +103 -0
package/README.md
CHANGED
|
@@ -14,8 +14,9 @@ pi event ◀── enforce ◀── decision ◀─────────
|
|
|
14
14
|
|
|
15
15
|
## Requirements
|
|
16
16
|
|
|
17
|
-
- The `appa` binary on `PATH`
|
|
18
|
-
|
|
17
|
+
- The `appa` binary on `PATH`: `curl -fsSL https://openappa.com/install.sh | sh`
|
|
18
|
+
(the extension installs it automatically when missing) — version 0.31.x
|
|
19
|
+
verified; see `docs/wire-notes.md` for the recorded contract
|
|
19
20
|
- An APPA runtime listening on loopback (default `127.0.0.1:8787`)
|
|
20
21
|
- A policy (`appa.toml`) that declares the tools your sessions may use —
|
|
21
22
|
`/appa init` writes a starter one, or copy `templates/appa.toml`
|
|
@@ -23,53 +24,84 @@ pi event ◀── enforce ◀── decision ◀─────────
|
|
|
23
24
|
## Install
|
|
24
25
|
|
|
25
26
|
```sh
|
|
26
|
-
pi install npm:pi-openappa #
|
|
27
|
+
pi install npm:pi-openappa # published on npm; indexed by the Pi gallery
|
|
27
28
|
pi install ./pi-openappa # from a checkout
|
|
28
29
|
```
|
|
29
30
|
|
|
31
|
+
## Smoke test
|
|
32
|
+
|
|
33
|
+
Ship and verify in one pass:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
just deploy # sync the lockfile, run checks, npm publish
|
|
37
|
+
just remove # drop a local-checkout install, if present
|
|
38
|
+
pi install npm:pi-openappa # install the published package
|
|
39
|
+
pi # any session: protection on, appa auto-installs
|
|
40
|
+
appa --version # OK — the runtime is on PATH
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`pi install` only registers the package — extension code runs when a session
|
|
44
|
+
starts, so `appa` appears after that first session, not before.
|
|
45
|
+
|
|
30
46
|
## Protect sessions
|
|
31
47
|
|
|
32
|
-
Protection is
|
|
48
|
+
Protection is **on by default**: every Pi session is guarded unless you opt
|
|
49
|
+
out. Opt-outs, most specific first:
|
|
33
50
|
|
|
34
|
-
- **
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
- **Per launch:** `APPA_GATE=0 pi` (and `APPA_GATE=1 pi` to force it on).
|
|
52
|
+
- **Per project:** create `<project>/.pi/no-openappa` — sessions started in
|
|
53
|
+
that directory run unguarded.
|
|
54
|
+
- **Globally:** `/appa off` once (marker `~/.config/pi-openappa/off`) — every
|
|
55
|
+
session everywhere runs unguarded until `/appa on`.
|
|
56
|
+
|
|
57
|
+
The project marker `<project>/.pi/openappa` names that project's policy
|
|
58
|
+
(absolute or cwd-relative) and re-enables protection even when globally off;
|
|
59
|
+
empty content falls back to `APPA_CONFIG` or APPA's default:
|
|
38
60
|
|
|
39
61
|
```sh
|
|
40
62
|
cd your-project && mkdir -p .pi && echo "appa.toml" > .pi/openappa
|
|
41
63
|
```
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
protected. `/appa off` disables.
|
|
45
|
-
- **Per launch:** `APPA_GATE=1 pi` (the `clappa`-style launcher route).
|
|
46
|
-
|
|
47
|
-
A gated session brings the runtime up on its own: `session_start` sends
|
|
65
|
+
A protected session brings the runtime up on its own: `session_start` sends
|
|
48
66
|
`appa hook --ensure-runtime` (passing `--config` when a policy is resolved),
|
|
49
67
|
but **does not wait for it** — the boot runs on a serialized background lane,
|
|
50
|
-
so a cold runtime, a first-run install, or a slow start never sits in
|
|
51
|
-
session-start path. Ordering is preserved: the SessionStart payload
|
|
52
|
-
lands before the first `PreToolUse`, and a runtime that cannot
|
|
53
|
-
that first call closed with the reason (silence never means
|
|
68
|
+
so a cold runtime, a first-run auto-install, or a slow start never sits in
|
|
69
|
+
Pi's session-start path. Ordering is preserved: the SessionStart payload
|
|
70
|
+
always lands before the first `PreToolUse`, and a runtime that cannot
|
|
71
|
+
answer fails that first call closed with the reason (silence never means
|
|
72
|
+
yes). A custom
|
|
54
73
|
`APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
|
|
55
74
|
refuses with exactly that reason instead of guessing.
|
|
56
75
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
When the default `appa` is missing from `PATH`, a protected session installs
|
|
77
|
+
it itself — once, with a UI notice — by running the same official script
|
|
78
|
+
(`curl -fsSL https://openappa.com/install.sh | sh`), then retries starting
|
|
79
|
+
the runtime. Sessions that name a custom `APPA_HOOK_BIN` are never
|
|
80
|
+
auto-installed.
|
|
81
|
+
|
|
82
|
+
While protected, a runtime that cannot answer blocks the call and the reason
|
|
83
|
+
is returned to the model — **silence never means yes**; `/appa off` always
|
|
84
|
+
works, even with every tool call blocked. One exception: with **no policy
|
|
85
|
+
anywhere** (`APPA_CONFIG`, marker content, and `~/.config/appa/appa.toml` all
|
|
86
|
+
absent) and no runtime answering, the session runs **unprotected** with one
|
|
87
|
+
startup warning naming the fixes — an unconfigured guard must not lock you
|
|
88
|
+
out of your own machine. A named policy that fails stays fail-closed.
|
|
89
|
+
Opted-out sessions never invoke the hook.
|
|
61
90
|
|
|
62
91
|
## Configuration
|
|
63
92
|
|
|
64
93
|
| Source | Default | Meaning |
|
|
65
94
|
|---|---|---|
|
|
66
|
-
| `APPA_GATE` | unset | `1`
|
|
67
|
-
| `.pi/openappa` | absent | Project marker:
|
|
95
|
+
| `APPA_GATE` | unset | `1` forces protection on for this launch; `0` forces it off |
|
|
96
|
+
| `.pi/openappa` | absent | Project marker: names that project's policy and forces protection on |
|
|
97
|
+
| `.pi/no-openappa` | absent | Project opt-out: sessions started there run unguarded |
|
|
68
98
|
| `~/.config/pi-openappa/settings.json` | absent | File settings: `config`, `runtimeUrl`, `hookBin`, `hookTimeoutMs`; env always wins, project markers beat `config` |
|
|
69
99
|
| `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
|
|
70
100
|
| `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
|
|
71
101
|
| `APPA_HOOK_BIN` | `appa` | Hook binary to invoke |
|
|
102
|
+
| `APPA_INSTALL_CMD` | `curl -fsSL https://openappa.com/install.sh \| sh` | Auto-install command for a missing default `appa` (pin a mirror or offline copy) |
|
|
72
103
|
| `APPA_HOOK_TIMEOUT_MS` | `15000` | Kill the hook after this long; the call is then blocked |
|
|
104
|
+
| `APPA_INSTALL_TIMEOUT_MS` | `120000` | Kill a stuck auto-install after this long |
|
|
73
105
|
|
|
74
106
|
Policy resolution order: `APPA_CONFIG`, then the project marker's content,
|
|
75
107
|
then `settings.json`'s `config`, then APPA's own default
|
|
@@ -123,9 +155,9 @@ JSON value per line / `}` / `expect allow|withhold|deny`); calls in one
|
|
|
123
155
|
file share a trajectory, so taint accumulates exactly as live. All three
|
|
124
156
|
gates run in `just check`.
|
|
125
157
|
|
|
126
|
-
`/appa` reports protection,
|
|
127
|
-
|
|
128
|
-
|
|
158
|
+
`/appa` reports protection, opt-out state, and runtime health; `/appa off`
|
|
159
|
+
disables protection globally (marker `~/.config/pi-openappa/off`) and
|
|
160
|
+
`/appa on` re-enables it, taking effect immediately including the
|
|
129
161
|
current session.
|
|
130
162
|
|
|
131
163
|
## Event mapping
|
|
@@ -156,7 +188,14 @@ policies declare them verbatim.
|
|
|
156
188
|
- **Subagents**: spawning is mediated as a plain tool call (deny blocks the
|
|
157
189
|
spawn). Child trajectories are not linked into the parent's label chain
|
|
158
190
|
yet; a gated child Pi process opens its own root trajectory.
|
|
159
|
-
-
|
|
191
|
+
- **On by default**: installing this extension guards every session and may
|
|
192
|
+
download and run openappa.com's install script once on first run. The
|
|
193
|
+
opt-outs above and `APPA_INSTALL_CMD` are the escapes.
|
|
194
|
+
- **Auto-install is `curl \| sh`**: a protected session with the default
|
|
195
|
+
binary missing downloads and runs the script with user privileges, at most
|
|
196
|
+
once per session start. Pre-install `appa` or pin `APPA_INSTALL_CMD` to
|
|
197
|
+
avoid it.
|
|
198
|
+
- The adapter never starts a runtime for opted-out sessions; auto-start needs
|
|
160
199
|
either `APPA_CONFIG` or an installed APPA deployment.
|
|
161
200
|
- OpenAPPA is Preview & RFC: wire surfaces may break without shims. The
|
|
162
201
|
entire wire contract lives in `src/hook-client.ts` and `src/adapter.ts`
|
package/extensions/index.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Thin by design: translate Pi events to `appa hook` invocations and enforce
|
|
5
5
|
* the answer. No policy logic lives here; the APPA runtime owns every
|
|
6
|
-
* decision. Protection is opt
|
|
7
|
-
*
|
|
8
|
-
* cannot answer, the call is blocked.
|
|
6
|
+
* decision. Protection is ON by default (opt out with APPA_GATE=0, a project
|
|
7
|
+
* `.pi/no-openappa` marker, or `/appa off`) and fail-closed while gated: if
|
|
8
|
+
* the runtime cannot answer, the call is blocked. A missing `appa` binary
|
|
9
|
+
* self-installs once on the background lane; a first run with no policy
|
|
10
|
+
* anywhere and no runtime runs the session unprotected with one warning.
|
|
9
11
|
*
|
|
10
12
|
* Wire facts and constraints: docs/wire-notes.md
|
|
11
13
|
*/
|
|
@@ -24,13 +26,15 @@ import {
|
|
|
24
26
|
stopPayload,
|
|
25
27
|
toolResponseFrom,
|
|
26
28
|
} from "../src/adapter.ts";
|
|
29
|
+
import { invokeAppaHook, resolveHookBin } from "../src/hook-client.ts";
|
|
27
30
|
import type { InvokeOptions } from "../src/hook-client.ts";
|
|
28
|
-
import {
|
|
31
|
+
import { installAppa, type InstallOutcome } from "../src/installer.ts";
|
|
29
32
|
import {
|
|
30
33
|
appaDefaultConfigPath,
|
|
34
|
+
appaDefaultPolicyExists,
|
|
31
35
|
captureGate,
|
|
32
36
|
checkHealth,
|
|
33
|
-
|
|
37
|
+
setGloballyOff,
|
|
34
38
|
settingsPath,
|
|
35
39
|
type GateState,
|
|
36
40
|
} from "../src/gate.ts";
|
|
@@ -52,6 +56,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
52
56
|
/** Launch-fixed protection state; null until the session starts. */
|
|
53
57
|
let gate: GateState | null = null;
|
|
54
58
|
let sessionId = "";
|
|
59
|
+
/** No policy anywhere and nothing answered: this session runs unprotected. */
|
|
60
|
+
let unprotected = false;
|
|
55
61
|
/**
|
|
56
62
|
* toolCallId → the exact `input` object sent in the PreToolUse payload.
|
|
57
63
|
* PostToolUse must echo it byte-identically or the runtime withholds the
|
|
@@ -64,9 +70,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
64
70
|
* through here in order, so the SessionStart payload (with
|
|
65
71
|
* `--ensure-runtime`) always lands before the first PreToolUse even though
|
|
66
72
|
* session_start does not wait for it. Startup stays off the fast-start
|
|
67
|
-
* lane: the runtime boot
|
|
68
|
-
* runtime first, and a runtime that cannot
|
|
69
|
-
* with the reason — never a slower session
|
|
73
|
+
* lane: the runtime boot — and a first run's auto-install — is paid by
|
|
74
|
+
* whichever tool call needs the runtime first, and a runtime that cannot
|
|
75
|
+
* answer fails that call closed with the reason — never a slower session
|
|
76
|
+
* start.
|
|
70
77
|
*/
|
|
71
78
|
let lane: Promise<unknown> = Promise.resolve();
|
|
72
79
|
const enqueue = <T>(run: () => Promise<T>): Promise<T> => {
|
|
@@ -78,7 +85,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
78
85
|
return outcome;
|
|
79
86
|
};
|
|
80
87
|
|
|
81
|
-
const gated = (): boolean => gate?.gated === true;
|
|
88
|
+
const gated = (): boolean => gate?.gated === true && !unprotected;
|
|
82
89
|
|
|
83
90
|
const hookOptions = (state: GateState, extra: InvokeOptions = {}): InvokeOptions => {
|
|
84
91
|
const options: InvokeOptions = { ...extra };
|
|
@@ -89,30 +96,88 @@ export default function (pi: ExtensionAPI): void {
|
|
|
89
96
|
pi.on("session_start", async (event, ctx) => {
|
|
90
97
|
gate = captureGate(process.env, ctx.cwd);
|
|
91
98
|
sessionId = ctx.sessionManager.getSessionId();
|
|
92
|
-
if (!gated
|
|
99
|
+
if (!gate.gated) return;
|
|
100
|
+
unprotected = false;
|
|
93
101
|
const state = gate;
|
|
94
102
|
const options: InvokeOptions = { ensureRuntime: true };
|
|
95
103
|
if (state.config !== undefined) options.config = state.config;
|
|
96
104
|
if (state.hookTimeoutMs !== undefined) options.timeoutMs = state.hookTimeoutMs;
|
|
97
105
|
// Backgrounded on purpose (see `lane`): a cold runtime boot — or a first
|
|
98
|
-
// run that still has to install
|
|
99
|
-
//
|
|
106
|
+
// run that still has to install the runtime — must not sit in Pi's
|
|
107
|
+
// session-start path. Auto-install, the retry, and the first-run
|
|
108
|
+
// unprotected decision all run serialized on the lane; failure surfaces
|
|
109
|
+
// as a warning when it settles.
|
|
100
110
|
void enqueue(async () => {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"
|
|
111
|
+
const notify = (message: string, kind: "info" | "warning"): void => {
|
|
112
|
+
if (ctx.hasUI) ctx.ui.notify(message, kind);
|
|
113
|
+
};
|
|
114
|
+
const payload = sessionStartPayload(sessionId, event.reason, ctx.cwd);
|
|
115
|
+
let outcome = await invokeAppaHook(payload, options);
|
|
116
|
+
let warnedAlready = false;
|
|
117
|
+
// A missing default `appa` is self-provisioned: run the official install
|
|
118
|
+
// script once, then retry bringing the runtime up. Custom APPA_HOOK_BINs
|
|
119
|
+
// are the user's own and never auto-installed.
|
|
120
|
+
if (outcome.binaryMissing && resolveHookBin(process.env) === "appa") {
|
|
121
|
+
notify(
|
|
122
|
+
"`appa` was not found — installing the OpenAPPA runtime " +
|
|
123
|
+
"(https://openappa.com/install.sh)…",
|
|
124
|
+
"info",
|
|
115
125
|
);
|
|
126
|
+
const install = await installAppa();
|
|
127
|
+
if (install.exitCode !== 0) {
|
|
128
|
+
warnedAlready = true;
|
|
129
|
+
notify(
|
|
130
|
+
`OpenAPPA auto-install failed: ${outcomeTail(install)}. ` +
|
|
131
|
+
"Tool calls stay blocked; install `appa` manually (see the README) or run /appa off.",
|
|
132
|
+
"warning",
|
|
133
|
+
);
|
|
134
|
+
} else {
|
|
135
|
+
const retry = await invokeAppaHook(payload, options);
|
|
136
|
+
if (retry.binaryMissing) {
|
|
137
|
+
warnedAlready = true;
|
|
138
|
+
notify(
|
|
139
|
+
"OpenAPPA auto-install finished but `appa` is still not on PATH. " +
|
|
140
|
+
`Installer output: ${outcomeTail(install)} — restart the session once PATH has it.`,
|
|
141
|
+
"warning",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
outcome = retry;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (outcome.exitCode !== 0) {
|
|
148
|
+
// No policy anywhere and nothing answering: run this session
|
|
149
|
+
// unprotected with one warning instead of fail-closed (the configured
|
|
150
|
+
// default). A named policy that fails stays fail-closed below.
|
|
151
|
+
if (
|
|
152
|
+
resolveHookBin(process.env) === "appa" &&
|
|
153
|
+
state.config === undefined &&
|
|
154
|
+
!appaDefaultPolicyExists(process.env) &&
|
|
155
|
+
!(await checkHealth(state.runtimeUrl)).ok
|
|
156
|
+
) {
|
|
157
|
+
unprotected = true;
|
|
158
|
+
if (!warnedAlready) {
|
|
159
|
+
notify(
|
|
160
|
+
"OpenAPPA is on by default, but no policy exists (run /appa init, " +
|
|
161
|
+
"or set APPA_CONFIG, .pi/openappa, or ~/.config/appa/appa.toml) and no runtime answers " +
|
|
162
|
+
`at ${state.runtimeUrl} — this session runs unprotected. ` +
|
|
163
|
+
"Write a policy, or run /appa off.",
|
|
164
|
+
"warning",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (ctx.hasUI && !warnedAlready) {
|
|
170
|
+
const remedy =
|
|
171
|
+
state.config === undefined
|
|
172
|
+
? " Provide a policy (this project's .pi/openappa, /appa init, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
|
|
173
|
+
: "";
|
|
174
|
+
ctx.ui.notify(
|
|
175
|
+
`OpenAPPA gated but the runtime did not answer (${state.runtimeUrl}): ` +
|
|
176
|
+
`${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
|
|
177
|
+
"Tool calls will be blocked until it answers.",
|
|
178
|
+
"warning",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
116
181
|
}
|
|
117
182
|
});
|
|
118
183
|
});
|
|
@@ -182,15 +247,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
182
247
|
|
|
183
248
|
pi.registerCommand("appa", {
|
|
184
249
|
description:
|
|
185
|
-
"OpenAPPA guard: status, `appa on|off`
|
|
250
|
+
"OpenAPPA guard: status, `appa on|off` global toggle, `appa init [project]` writes a starter policy",
|
|
186
251
|
getArgumentCompletions: (argumentPrefix) => {
|
|
187
252
|
const items = [
|
|
188
253
|
{
|
|
189
254
|
value: "on",
|
|
190
255
|
label: "on",
|
|
191
|
-
description: "
|
|
256
|
+
description: "Re-enable default-on protection for every session",
|
|
192
257
|
},
|
|
193
|
-
{ value: "off", label: "off", description: "Disable
|
|
258
|
+
{ value: "off", label: "off", description: "Disable protection globally (/appa on re-enables)" },
|
|
194
259
|
{
|
|
195
260
|
value: "init",
|
|
196
261
|
label: "init",
|
|
@@ -212,13 +277,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
212
277
|
const arg = tokens[0] ?? "";
|
|
213
278
|
|
|
214
279
|
if (arg === "on" || arg === "off") {
|
|
215
|
-
|
|
280
|
+
setGloballyOff(process.env, arg === "off");
|
|
216
281
|
gate = captureGate(process.env, ctx.cwd);
|
|
217
282
|
if (ctx.hasUI) {
|
|
218
283
|
ctx.ui.notify(
|
|
219
284
|
arg === "on"
|
|
220
|
-
? "OpenAPPA
|
|
221
|
-
: "OpenAPPA
|
|
285
|
+
? "OpenAPPA protection re-enabled: on by default for every session."
|
|
286
|
+
: "OpenAPPA protection disabled globally (/appa on re-enables).",
|
|
222
287
|
"info",
|
|
223
288
|
);
|
|
224
289
|
}
|
|
@@ -236,14 +301,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
236
301
|
const mode =
|
|
237
302
|
state.source === "project"
|
|
238
303
|
? "project (.pi/openappa)"
|
|
239
|
-
: state.source === "env"
|
|
304
|
+
: state.source === "env-on"
|
|
240
305
|
? "launch (APPA_GATE=1)"
|
|
241
|
-
: state.source === "
|
|
242
|
-
? "
|
|
243
|
-
:
|
|
306
|
+
: state.source === "env-off"
|
|
307
|
+
? "launch opt-out (APPA_GATE=0)"
|
|
308
|
+
: state.source === "project-off"
|
|
309
|
+
? "project opt-out (.pi/no-openappa)"
|
|
310
|
+
: state.source === "global-off"
|
|
311
|
+
? "global opt-out (/appa on re-enables)"
|
|
312
|
+
: "on by default (/appa off disables)";
|
|
244
313
|
const lines: string[] = [];
|
|
245
314
|
lines.push(state.gated ? `Protection: ON (session ${sessionId || "not started"})` : `Protection: off — ${mode}`);
|
|
246
|
-
if (
|
|
315
|
+
if (unprotected) {
|
|
316
|
+
lines.push("Session: unprotected — no policy found; see the startup warning.");
|
|
317
|
+
}
|
|
318
|
+
if (state.gated && !unprotected) lines.push(`Mode: ${mode}`);
|
|
247
319
|
if (state.config !== undefined) lines.push(`Policy: ${state.config}`);
|
|
248
320
|
lines.push(`Runtime: ${state.runtimeUrl}`);
|
|
249
321
|
const health = await checkHealth(state.runtimeUrl);
|
|
@@ -303,7 +375,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
303
375
|
writeFileSync(settings, `${JSON.stringify({ config: policy }, null, 2)}\n`);
|
|
304
376
|
lines.push(`Wrote ${settings} pointing at it.`);
|
|
305
377
|
}
|
|
306
|
-
lines.push("
|
|
378
|
+
lines.push("Protection is on by default; /appa off disables it, /appa init project scopes a policy to one project.");
|
|
307
379
|
notify(lines.join(" "));
|
|
308
380
|
} catch (error) {
|
|
309
381
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -312,6 +384,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
312
384
|
}
|
|
313
385
|
}
|
|
314
386
|
|
|
387
|
+
/** Last ~200 chars of installer output, whitespace-normalized, for notices. */
|
|
388
|
+
function outcomeTail(outcome: InstallOutcome): string {
|
|
389
|
+
const text = `${outcome.stderr} ${outcome.stdout}`.trim().replace(/\s+/g, " ");
|
|
390
|
+
if (text === "") return "(no output)";
|
|
391
|
+
return text.length > 200 ? `…${text.slice(-200)}` : text;
|
|
392
|
+
}
|
|
393
|
+
|
|
315
394
|
function joinContent(content: ReadonlyArray<unknown>): string {
|
|
316
395
|
const parts: string[] = [];
|
|
317
396
|
for (const item of content) {
|
package/package.json
CHANGED
package/src/gate.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session gate: protection is
|
|
2
|
+
* Session gate: protection is ON by default and fixed at session start.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
4
|
+
* Opt-outs, most specific first:
|
|
5
|
+
* - APPA_GATE=1 / APPA_GATE=0 force on/off for one launch,
|
|
6
|
+
* - a project opt-out marker `<cwd>/.pi/no-openappa`,
|
|
7
|
+
* - a project marker `<cwd>/.pi/openappa` (which also names that project's
|
|
8
|
+
* policy, absolute or cwd-relative),
|
|
9
|
+
* - the global `/appa off` marker below; `/appa on` clears it.
|
|
10
|
+
* Otherwise the session is protected (the default).
|
|
9
11
|
*
|
|
10
12
|
* An explicit APPA_CONFIG always wins as the policy source; otherwise a
|
|
11
13
|
* project marker's content is used; otherwise the settings file's `config`;
|
|
12
14
|
* otherwise APPA's own default. The gate is
|
|
13
15
|
* captured once per session so a session cannot disable its own protection
|
|
14
16
|
* mid-run; `/appa on|off` are deliberate user commands and do re-resolve.
|
|
17
|
+
* The legacy `always-on` marker from opt-in days is ignored; `/appa on`
|
|
18
|
+
* removes it.
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
21
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -26,7 +30,13 @@ function baseConfigDir(env: NodeJS.ProcessEnv): string {
|
|
|
26
30
|
return join(env.HOME ?? "", ".config");
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
/** Global opt-out written by `/appa off`; `/appa on` removes it. */
|
|
34
|
+
export function globalOffMarkerPath(env: NodeJS.ProcessEnv): string {
|
|
35
|
+
return join(baseConfigDir(env), "pi-openappa", "off");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Legacy opt-in marker from before default-on; ignored, cleared by `/appa on`. */
|
|
39
|
+
export function legacyAlwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
|
|
30
40
|
return join(baseConfigDir(env), "pi-openappa", "always-on");
|
|
31
41
|
}
|
|
32
42
|
|
|
@@ -83,21 +93,45 @@ export function projectMarkerPath(cwd: string): string {
|
|
|
83
93
|
return join(cwd, ".pi", "openappa");
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
export function
|
|
87
|
-
return
|
|
96
|
+
export function projectNoMarkerPath(cwd: string): string {
|
|
97
|
+
return join(cwd, ".pi", "no-openappa");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isGloballyOff(env: NodeJS.ProcessEnv): boolean {
|
|
101
|
+
return existsSync(globalOffMarkerPath(env));
|
|
88
102
|
}
|
|
89
103
|
|
|
90
|
-
export function
|
|
91
|
-
const marker =
|
|
92
|
-
if (
|
|
104
|
+
export function setGloballyOff(env: NodeJS.ProcessEnv, off: boolean): void {
|
|
105
|
+
const marker = globalOffMarkerPath(env);
|
|
106
|
+
if (off) {
|
|
93
107
|
mkdirSync(dirname(marker), { recursive: true });
|
|
94
108
|
writeFileSync(marker, "");
|
|
95
109
|
} else {
|
|
96
110
|
rmSync(marker, { force: true });
|
|
111
|
+
rmSync(legacyAlwaysOnMarkerPath(env), { force: true });
|
|
97
112
|
}
|
|
98
113
|
}
|
|
99
114
|
|
|
100
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Does APPA's own default policy exist? Heuristic mirror of the runtime's
|
|
117
|
+
* lookup: `$XDG_CONFIG_HOME/appa/appa.toml` or `~/.config/appa/appa.toml`.
|
|
118
|
+
*/
|
|
119
|
+
export function appaDefaultPolicyExists(env: NodeJS.ProcessEnv): boolean {
|
|
120
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
121
|
+
const candidates = [
|
|
122
|
+
...(xdg !== undefined && xdg !== "" ? [join(xdg, "appa", "appa.toml")] : []),
|
|
123
|
+
join(env.HOME ?? "", ".config", "appa", "appa.toml"),
|
|
124
|
+
];
|
|
125
|
+
return candidates.some((candidate) => existsSync(candidate));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type GateSource =
|
|
129
|
+
| "env-on"
|
|
130
|
+
| "env-off"
|
|
131
|
+
| "project"
|
|
132
|
+
| "project-off"
|
|
133
|
+
| "global-off"
|
|
134
|
+
| "default";
|
|
101
135
|
|
|
102
136
|
export interface GateState {
|
|
103
137
|
/** Protection active for this session. */
|
|
@@ -137,29 +171,47 @@ export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
|
|
|
137
171
|
? env.APPA_CONFIG
|
|
138
172
|
: undefined;
|
|
139
173
|
const projectGated = cwd !== undefined && existsSync(projectMarkerPath(cwd));
|
|
174
|
+
const projectOff = cwd !== undefined && existsSync(projectNoMarkerPath(cwd));
|
|
140
175
|
const projectCfg =
|
|
141
176
|
projectGated && cwd !== undefined ? projectConfig(cwd) : undefined;
|
|
142
177
|
// Precedence: launch env, then the project marker (per-project intent),
|
|
143
178
|
// then the global settings file (see readSettings).
|
|
144
179
|
const config = explicitConfig ?? projectCfg ?? settings.config;
|
|
145
180
|
|
|
146
|
-
|
|
181
|
+
// An explicit launch choice beats every marker.
|
|
182
|
+
if (env.APPA_GATE === "1") {
|
|
147
183
|
return {
|
|
148
184
|
gated: true,
|
|
149
|
-
source:
|
|
185
|
+
source: "env-on",
|
|
150
186
|
...base,
|
|
151
187
|
...(config !== undefined ? { config } : {}),
|
|
152
188
|
};
|
|
153
189
|
}
|
|
154
|
-
if (
|
|
190
|
+
if (env.APPA_GATE === "0") {
|
|
191
|
+
return { gated: false, source: "env-off", ...base };
|
|
192
|
+
}
|
|
193
|
+
// Project level: an opt-out beats the project's own opt-in.
|
|
194
|
+
if (projectOff) {
|
|
195
|
+
return { gated: false, source: "project-off", ...base };
|
|
196
|
+
}
|
|
197
|
+
if (projectGated) {
|
|
155
198
|
return {
|
|
156
199
|
gated: true,
|
|
157
|
-
source: "
|
|
200
|
+
source: "project",
|
|
158
201
|
...base,
|
|
159
202
|
...(config !== undefined ? { config } : {}),
|
|
160
203
|
};
|
|
161
204
|
}
|
|
162
|
-
|
|
205
|
+
// Global opt-out, then the default: protection on.
|
|
206
|
+
if (isGloballyOff(env)) {
|
|
207
|
+
return { gated: false, source: "global-off", ...base };
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
gated: true,
|
|
211
|
+
source: "default",
|
|
212
|
+
...base,
|
|
213
|
+
...(config !== undefined ? { config } : {}),
|
|
214
|
+
};
|
|
163
215
|
}
|
|
164
216
|
|
|
165
217
|
export interface HealthResult {
|
package/src/hook-client.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface HookOutcome {
|
|
|
18
18
|
stdout: string;
|
|
19
19
|
stderr: string;
|
|
20
20
|
timedOut: boolean;
|
|
21
|
+
/** The hook binary itself could not be spawned (ENOENT). */
|
|
22
|
+
binaryMissing: boolean;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface InvokeOptions {
|
|
@@ -94,10 +96,11 @@ export async function invokeAppaHook(
|
|
|
94
96
|
stdout,
|
|
95
97
|
stderr: `${stderr}appa hook timed out after ${timeoutMs}ms`.trim(),
|
|
96
98
|
timedOut: true,
|
|
99
|
+
binaryMissing: false,
|
|
97
100
|
});
|
|
98
101
|
return;
|
|
99
102
|
}
|
|
100
|
-
resolve({ exitCode, stdout, stderr, timedOut: false });
|
|
103
|
+
resolve({ exitCode, stdout, stderr, timedOut: false, binaryMissing: false });
|
|
101
104
|
};
|
|
102
105
|
|
|
103
106
|
child.on("error", (error) => {
|
|
@@ -120,11 +123,13 @@ export async function invokeAppaHook(
|
|
|
120
123
|
|
|
121
124
|
function spawnFailure(error: unknown): HookOutcome {
|
|
122
125
|
const detail = error instanceof Error ? error.message : String(error);
|
|
126
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
123
127
|
return {
|
|
124
128
|
exitCode: -1,
|
|
125
129
|
stdout: "",
|
|
126
130
|
stderr: `appa hook failed to start: ${detail}`,
|
|
127
131
|
timedOut: false,
|
|
132
|
+
binaryMissing: code === "ENOENT",
|
|
128
133
|
};
|
|
129
134
|
}
|
|
130
135
|
|
package/src/installer.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-installer for the OpenAPPA runtime binary.
|
|
3
|
+
*
|
|
4
|
+
* Runs the official install pipeline through `sh -c` — nothing else. It never
|
|
5
|
+
* decides: the extension wiring owns when an install is appropriate and what
|
|
6
|
+
* to report. The command and timeout honor APPA_INSTALL_CMD and
|
|
7
|
+
* APPA_INSTALL_TIMEOUT_MS so mirrors, offline copies, and slow links work.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_INSTALL_CMD = "curl -fsSL https://openappa.com/install.sh | sh";
|
|
13
|
+
|
|
14
|
+
export interface InstallOutcome {
|
|
15
|
+
exitCode: number;
|
|
16
|
+
stdout: string;
|
|
17
|
+
stderr: string;
|
|
18
|
+
timedOut: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
export function resolveInstallCmd(env: NodeJS.ProcessEnv): string {
|
|
24
|
+
const cmd = env.APPA_INSTALL_CMD;
|
|
25
|
+
return cmd !== undefined && cmd !== "" ? cmd : DEFAULT_INSTALL_CMD;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveInstallTimeoutMs(env: NodeJS.ProcessEnv): number {
|
|
29
|
+
const raw = Number(env.APPA_INSTALL_TIMEOUT_MS);
|
|
30
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function installAppa(
|
|
34
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
35
|
+
): Promise<InstallOutcome> {
|
|
36
|
+
const timeoutMs = resolveInstallTimeoutMs(env);
|
|
37
|
+
return await new Promise<InstallOutcome>((resolve) => {
|
|
38
|
+
let child;
|
|
39
|
+
try {
|
|
40
|
+
child = spawn("sh", ["-c", resolveInstallCmd(env)], {
|
|
41
|
+
env,
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
resolve(installFailure(error));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let stdout = "";
|
|
50
|
+
let stderr = "";
|
|
51
|
+
let timedOut = false;
|
|
52
|
+
let settled = false;
|
|
53
|
+
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
timedOut = true;
|
|
56
|
+
child.kill("SIGKILL");
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
|
|
59
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
60
|
+
stdout += chunk.toString("utf8");
|
|
61
|
+
});
|
|
62
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
63
|
+
stderr += chunk.toString("utf8");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const finish = (exitCode: number) => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
if (timedOut) {
|
|
71
|
+
resolve({
|
|
72
|
+
exitCode: -1,
|
|
73
|
+
stdout,
|
|
74
|
+
stderr: `${stderr}appa install timed out after ${timeoutMs}ms`.trim(),
|
|
75
|
+
timedOut: true,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
resolve({ exitCode, stdout, stderr, timedOut: false });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
child.on("error", (error) => {
|
|
83
|
+
resolve(mergeOutcome(installFailure(error), stderr));
|
|
84
|
+
settled = true;
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
});
|
|
87
|
+
child.on("close", (code) => finish(code ?? -1));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function installFailure(error: unknown): InstallOutcome {
|
|
92
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
93
|
+
return {
|
|
94
|
+
exitCode: -1,
|
|
95
|
+
stdout: "",
|
|
96
|
+
stderr: `appa install failed to start: ${detail}`,
|
|
97
|
+
timedOut: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mergeOutcome(base: InstallOutcome, stderrSoFar: string): InstallOutcome {
|
|
102
|
+
return { ...base, stderr: `${stderrSoFar}${base.stderr}`.trim() };
|
|
103
|
+
}
|