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 CHANGED
@@ -14,8 +14,9 @@ pi event ◀── enforce ◀── decision ◀─────────
14
14
 
15
15
  ## Requirements
16
16
 
17
- - The `appa` binary on `PATH` ([install](https://openappa.com)) — version
18
- 0.31.x verified; see `docs/wire-notes.md` for the recorded contract
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 # once published
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 opt-in, in one of three ways:
48
+ Protection is **on by default**: every Pi session is guarded unless you opt
49
+ out. Opt-outs, most specific first:
33
50
 
34
- - **Project-scoped (recommended):** create `<project>/.pi/openappa` — sessions
35
- started in that directory are protected, sessions elsewhere are not. The
36
- marker's optional content names that project's policy (absolute or
37
- cwd-relative); empty content falls back to `APPA_CONFIG` or APPA's default:
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
- - **Built-in:** run `/appa on` once — every Pi session everywhere is
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 Pi's
51
- session-start path. Ordering is preserved: the SessionStart payload always
52
- lands before the first `PreToolUse`, and a runtime that cannot answer fails
53
- that first call closed with the reason (silence never means yes). A custom
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
- While gated, a runtime that cannot answer blocks the call and the reason is
58
- returned to the model — **silence never means yes**. If no policy exists the
59
- startup warning names the exact outs; `/appa off` always works, even with
60
- every tool call blocked. Ungated sessions never invoke the hook.
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` protects this session (read once at launch) |
67
- | `.pi/openappa` | absent | Project marker: gates sessions started in that directory; optional content = policy path |
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, always-on state, and runtime health; `/appa on`
127
- and `/appa off` toggle always-on protection (marker:
128
- `~/.config/pi-openappa/always-on`), taking effect immediately including the
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
- - The adapter never starts a runtime for ungated sessions; auto-start needs
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`
@@ -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-in per session (APPA_GATE=1 at launch, a
7
- * project marker, or always-on) and fail-closed while gated: if the runtime
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 { invokeAppaHook } from "../src/hook-client.ts";
31
+ import { installAppa, type InstallOutcome } from "../src/installer.ts";
29
32
  import {
30
33
  appaDefaultConfigPath,
34
+ appaDefaultPolicyExists,
31
35
  captureGate,
32
36
  checkHealth,
33
- setAlwaysOn,
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 cost is paid by whichever tool call needs the
68
- * runtime first, and a runtime that cannot answer fails that call closed
69
- * with the reason — never a slower session start.
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()) return;
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 or update the runtime — must not sit in
99
- // Pi's session-start path. Failure surfaces as a warning when it settles.
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 outcome = await invokeAppaHook(
102
- sessionStartPayload(sessionId, event.reason, ctx.cwd),
103
- options,
104
- );
105
- if (outcome.exitCode !== 0 && ctx.hasUI) {
106
- const remedy =
107
- options.config === undefined
108
- ? " Provide a policy (this project's .pi/openappa, /appa init, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
109
- : "";
110
- ctx.ui.notify(
111
- `OpenAPPA gated but the runtime did not answer (${state.runtimeUrl}): ` +
112
- `${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
113
- "Tool calls will be blocked until it answers.",
114
- "warning",
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` always-on, `appa init [project]` writes a starter policy",
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: "Protect every future Pi session (always-on marker)",
256
+ description: "Re-enable default-on protection for every session",
192
257
  },
193
- { value: "off", label: "off", description: "Disable always-on protection" },
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
- setAlwaysOn(process.env, arg === "on");
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 always-on enabled: every future Pi session is protected."
221
- : "OpenAPPA always-on disabled.",
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 === "always-on"
242
- ? "always-on (/appa off to disable)"
243
- : "off — /appa on enables it for every session";
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 (state.gated) lines.push(`Mode: ${mode}`);
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("Protect sessions with /appa on, APPA_GATE=1, or /appa init project.");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openappa",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Thin OpenAPPA guard extension for Pi: gates tool calls through the APPA runtime. No policy logic lives here.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/gate.ts CHANGED
@@ -1,17 +1,21 @@
1
1
  /**
2
- * Session gate: protection is opt-in, fixed at session start.
2
+ * Session gate: protection is ON by default and fixed at session start.
3
3
  *
4
- * Three ways a session becomes protected, most specific first for reporting:
5
- * - a project marker `<cwd>/.pi/openappa` (project-scoped; optional content
6
- * names that project's policy, absolute or cwd-relative),
7
- * - launched with APPA_GATE=1 (the launcher route, mirroring `clappa`), or
8
- * - always-on mode, persisted by `/appa on` (marker file below).
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
- export function alwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
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 isAlwaysOn(env: NodeJS.ProcessEnv): boolean {
87
- return existsSync(alwaysOnMarkerPath(env));
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 setAlwaysOn(env: NodeJS.ProcessEnv, on: boolean): void {
91
- const marker = alwaysOnMarkerPath(env);
92
- if (on) {
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
- export type GateSource = "project" | "env" | "always-on" | "off";
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
- if (env.APPA_GATE === "1" || projectGated) {
181
+ // An explicit launch choice beats every marker.
182
+ if (env.APPA_GATE === "1") {
147
183
  return {
148
184
  gated: true,
149
- source: projectGated ? "project" : "env",
185
+ source: "env-on",
150
186
  ...base,
151
187
  ...(config !== undefined ? { config } : {}),
152
188
  };
153
189
  }
154
- if (isAlwaysOn(env)) {
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: "always-on",
200
+ source: "project",
158
201
  ...base,
159
202
  ...(config !== undefined ? { config } : {}),
160
203
  };
161
204
  }
162
- return { gated: false, source: "off", ...base };
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 {
@@ -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
 
@@ -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
+ }