pi-openappa 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,61 +14,92 @@ 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
 
22
23
  ## Install
23
24
 
24
25
  ```sh
25
- pi install npm:pi-openappa # once published
26
+ pi install npm:pi-openappa # published on npm; indexed by the Pi gallery
26
27
  pi install ./pi-openappa # from a checkout
27
28
  ```
28
29
 
30
+ ## Smoke test
31
+
32
+ Ship and verify in one pass:
33
+
34
+ ```sh
35
+ just deploy # sync the lockfile, run checks, npm publish
36
+ just remove # drop a local-checkout install, if present
37
+ pi install npm:pi-openappa # install the published package
38
+ pi # any session: protection on, appa auto-installs
39
+ appa --version # OK — the runtime is on PATH
40
+ ```
41
+
42
+ `pi install` only registers the package — extension code runs when a session
43
+ starts, so `appa` appears after that first session, not before.
44
+
29
45
  ## Protect sessions
30
46
 
31
- Protection is opt-in, in one of three ways:
47
+ Protection is **on by default**: every Pi session is guarded unless you opt
48
+ out. Opt-outs, most specific first:
32
49
 
33
- - **Project-scoped (recommended):** create `<project>/.pi/openappa` — sessions
34
- started in that directory are protected, sessions elsewhere are not. The
35
- marker's optional content names that project's policy (absolute or
36
- cwd-relative); empty content falls back to `APPA_CONFIG` or APPA's default:
50
+ - **Per launch:** `APPA_GATE=0 pi` (and `APPA_GATE=1 pi` to force it on).
51
+ - **Per project:** create `<project>/.pi/no-openappa` — sessions started in
52
+ that directory run unguarded.
53
+ - **Globally:** `/appa off` once (marker `~/.config/pi-openappa/off`) — every
54
+ session everywhere runs unguarded until `/appa on`.
55
+
56
+ The project marker `<project>/.pi/openappa` names that project's policy
57
+ (absolute or cwd-relative) and re-enables protection even when globally off;
58
+ empty content falls back to `APPA_CONFIG` or APPA's default:
37
59
 
38
60
  ```sh
39
61
  cd your-project && mkdir -p .pi && echo "appa.toml" > .pi/openappa
40
62
  ```
41
63
 
42
- - **Built-in:** run `/appa on` once — every Pi session everywhere is
43
- protected. `/appa off` disables.
44
- - **Per launch:** `APPA_GATE=1 pi` (the `clappa`-style launcher route).
45
-
46
- A gated session brings the runtime up on its own: `session_start` invokes
64
+ A protected session brings the runtime up on its own: `session_start` invokes
47
65
  `appa hook --ensure-runtime`, passing `--config "$APPA_CONFIG"` when set and
48
66
  otherwise letting APPA use its own default policy (`~/.config/appa/appa.toml`).
49
67
  Protected sessions therefore need zero manual server management. A custom
50
68
  `APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
51
69
  refuses with exactly that reason instead of guessing.
52
70
 
53
- While gated, a runtime that cannot answer blocks the call and the reason is
54
- returned to the model — **silence never means yes**. If no policy exists the
55
- startup warning names the exact outs; `/appa off` always works, even with
56
- every tool call blocked. Ungated sessions never invoke the hook.
71
+ When the default `appa` is missing from `PATH`, a protected session installs
72
+ it itself — once, with a UI notice — by running the same official script
73
+ (`curl -fsSL https://openappa.com/install.sh | sh`), then retries starting
74
+ the runtime. Sessions that name a custom `APPA_HOOK_BIN` are never
75
+ auto-installed.
76
+
77
+ While protected, a runtime that cannot answer blocks the call and the reason
78
+ is returned to the model — **silence never means yes**; `/appa off` always
79
+ works, even with every tool call blocked. One exception: with **no policy
80
+ anywhere** (`APPA_CONFIG`, marker content, and `~/.config/appa/appa.toml` all
81
+ absent) and no runtime answering, the session runs **unprotected** with one
82
+ startup warning naming the fixes — an unconfigured guard must not lock you
83
+ out of your own machine. A named policy that fails stays fail-closed.
84
+ Opted-out sessions never invoke the hook.
57
85
 
58
86
  ## Configuration
59
87
 
60
88
  | Variable | Default | Meaning |
61
89
  |---|---|---|
62
- | `APPA_GATE` | unset | `1` protects this session (read once at launch) |
63
- | `.pi/openappa` | absent | Project marker: gates sessions started in that directory; optional content = policy path |
90
+ | `APPA_GATE` | unset | `1` forces protection on for this launch; `0` forces it off |
91
+ | `.pi/openappa` | absent | Project marker: names that project's policy and forces protection on |
92
+ | `.pi/no-openappa` | absent | Project opt-out: sessions started there run unguarded |
64
93
  | `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
65
94
  | `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
66
95
  | `APPA_HOOK_BIN` | `appa` | Hook binary to invoke |
96
+ | `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) |
67
97
  | `APPA_HOOK_TIMEOUT_MS` | `15000` | Kill the hook after this long; the call is then blocked |
98
+ | `APPA_INSTALL_TIMEOUT_MS` | `120000` | Kill a stuck auto-install after this long |
68
99
 
69
- `/appa` reports protection, always-on state, and runtime health; `/appa on`
70
- and `/appa off` toggle always-on protection (marker:
71
- `~/.config/pi-openappa/always-on`), taking effect immediately including the
100
+ `/appa` reports protection, opt-out state, and runtime health; `/appa off`
101
+ disables protection globally (marker `~/.config/pi-openappa/off`) and
102
+ `/appa on` re-enables it, taking effect immediately including the
72
103
  current session.
73
104
 
74
105
  ## Event mapping
@@ -99,7 +130,14 @@ policies declare them verbatim.
99
130
  - **Subagents**: spawning is mediated as a plain tool call (deny blocks the
100
131
  spawn). Child trajectories are not linked into the parent's label chain
101
132
  yet; a gated child Pi process opens its own root trajectory.
102
- - The adapter never starts a runtime for ungated sessions; auto-start needs
133
+ - **On by default**: installing this extension guards every session and may
134
+ download and run openappa.com's install script once on first run. The
135
+ opt-outs above and `APPA_INSTALL_CMD` are the escapes.
136
+ - **Auto-install is `curl \| sh`**: a protected session with the default
137
+ binary missing downloads and runs the script with user privileges, at most
138
+ once per session start. Pre-install `appa` or pin `APPA_INSTALL_CMD` to
139
+ avoid it.
140
+ - The adapter never starts a runtime for opted-out sessions; auto-start needs
103
141
  either `APPA_CONFIG` or an installed APPA deployment.
104
142
  - OpenAPPA is Preview & RFC: wire surfaces may break without shims. The
105
143
  entire wire contract lives in `src/hook-client.ts` and `src/adapter.ts`
@@ -20,8 +20,15 @@ import {
20
20
  stopPayload,
21
21
  toolResponseFrom,
22
22
  } from "../src/adapter.ts";
23
- import { invokeAppaHook } from "../src/hook-client.ts";
24
- import { captureGate, checkHealth, setAlwaysOn, type GateState } from "../src/gate.ts";
23
+ import { invokeAppaHook, resolveHookBin, type HookOutcome } from "../src/hook-client.ts";
24
+ import { installAppa, type InstallOutcome } from "../src/installer.ts";
25
+ import {
26
+ appaDefaultPolicyExists,
27
+ captureGate,
28
+ checkHealth,
29
+ setGloballyOff,
30
+ type GateState,
31
+ } from "../src/gate.ts";
25
32
 
26
33
  interface TextPart {
27
34
  type: "text";
@@ -32,6 +39,8 @@ export default function (pi: ExtensionAPI): void {
32
39
  /** Launch-fixed protection state; null until the session starts. */
33
40
  let gate: GateState | null = null;
34
41
  let sessionId = "";
42
+ /** No policy anywhere and nothing answered: this session runs unprotected. */
43
+ let unprotected = false;
35
44
  /**
36
45
  * toolCallId → the exact `input` object sent in the PreToolUse payload.
37
46
  * PostToolUse must echo it byte-identically or the runtime withholds the
@@ -39,21 +48,77 @@ export default function (pi: ExtensionAPI): void {
39
48
  */
40
49
  const pendingInputs = new Map<string, Record<string, unknown>>();
41
50
 
42
- const gated = (): boolean => gate?.gated === true;
51
+ const gated = (): boolean => gate?.gated === true && !unprotected;
43
52
 
44
53
  pi.on("session_start", async (event, ctx) => {
45
54
  gate = captureGate(process.env, ctx.cwd);
46
55
  sessionId = ctx.sessionManager.getSessionId();
47
- if (!gated()) return;
56
+ if (!gate.gated) return;
57
+ unprotected = false;
48
58
 
49
- const outcome = await invokeAppaHook(
50
- sessionStartPayload(sessionId, event.reason, ctx.cwd),
51
- {
52
- ensureRuntime: true,
53
- ...(gate.config !== undefined ? { config: gate.config } : {}),
54
- },
55
- );
56
- if (outcome.exitCode !== 0 && ctx.hasUI) {
59
+ const payload = sessionStartPayload(sessionId, event.reason, ctx.cwd);
60
+ const options = {
61
+ ensureRuntime: true,
62
+ ...(gate.config !== undefined ? { config: gate.config } : {}),
63
+ };
64
+ const notify = (message: string, kind: "info" | "warning"): void => {
65
+ if (ctx.hasUI) ctx.ui.notify(message, kind);
66
+ };
67
+ let outcome = await invokeAppaHook(payload, options);
68
+ let warnedAlready = false;
69
+ // A missing default `appa` is self-provisioned: run the official install
70
+ // script once, then retry bringing the runtime up. Custom APPA_HOOK_BINs
71
+ // are the user's own and never auto-installed.
72
+ if (outcome.binaryMissing && resolveHookBin(process.env) === "appa") {
73
+ notify(
74
+ "`appa` was not found — installing the OpenAPPA runtime " +
75
+ "(https://openappa.com/install.sh)…",
76
+ "info",
77
+ );
78
+ const install = await installAppa();
79
+ if (install.exitCode !== 0) {
80
+ warnedAlready = true;
81
+ notify(
82
+ `OpenAPPA auto-install failed: ${outcomeTail(install)}. ` +
83
+ "Tool calls stay blocked; install `appa` manually (see the README) or run /appa off.",
84
+ "warning",
85
+ );
86
+ } else {
87
+ const retry = await invokeAppaHook(payload, options);
88
+ if (retry.binaryMissing) {
89
+ warnedAlready = true;
90
+ notify(
91
+ "OpenAPPA auto-install finished but `appa` is still not on PATH. " +
92
+ `Installer output: ${outcomeTail(install)} — restart the session once PATH has it.`,
93
+ "warning",
94
+ );
95
+ }
96
+ outcome = retry;
97
+ }
98
+ }
99
+ if (outcome.exitCode !== 0) {
100
+ // No policy anywhere and nothing answering: run this session
101
+ // unprotected with one warning instead of fail-closed (the configured
102
+ // default). A named policy that fails stays fail-closed below.
103
+ if (
104
+ resolveHookBin(process.env) === "appa" &&
105
+ gate.config === undefined &&
106
+ !appaDefaultPolicyExists(process.env) &&
107
+ !(await checkHealth(gate.runtimeUrl)).ok
108
+ ) {
109
+ unprotected = true;
110
+ if (!warnedAlready) {
111
+ notify(
112
+ "OpenAPPA is on by default, but no policy exists (APPA_CONFIG, " +
113
+ ".pi/openappa, or ~/.config/appa/appa.toml) and no runtime answers " +
114
+ `at ${gate.runtimeUrl} — this session runs unprotected. ` +
115
+ "Write a policy, or run /appa off.",
116
+ "warning",
117
+ );
118
+ }
119
+ return;
120
+ }
121
+ if (ctx.hasUI && !warnedAlready) {
57
122
  const remedy =
58
123
  gate.config === undefined
59
124
  ? " Provide a policy (this project's .pi/openappa, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
@@ -64,6 +129,7 @@ export default function (pi: ExtensionAPI): void {
64
129
  "Tool calls will be blocked until it answers.",
65
130
  "warning",
66
131
  );
132
+ }
67
133
  }
68
134
  });
69
135
 
@@ -123,17 +189,17 @@ export default function (pi: ExtensionAPI): void {
123
189
  });
124
190
 
125
191
  pi.registerCommand("appa", {
126
- description: "Show OpenAPPA status; `appa on|off` toggles always-on protection",
192
+ description: "Show OpenAPPA status; `appa on|off` toggles protection globally",
127
193
  handler: async (args, ctx) => {
128
194
  const arg = args.trim();
129
195
  if (arg === "on" || arg === "off") {
130
- setAlwaysOn(process.env, arg === "on");
196
+ setGloballyOff(process.env, arg === "off");
131
197
  gate = captureGate(process.env, ctx.cwd);
132
198
  if (ctx.hasUI) {
133
199
  ctx.ui.notify(
134
200
  arg === "on"
135
- ? "OpenAPPA always-on enabled: every future Pi session is protected."
136
- : "OpenAPPA always-on disabled.",
201
+ ? "OpenAPPA protection re-enabled: on by default for every session."
202
+ : "OpenAPPA protection disabled globally (/appa on re-enables).",
137
203
  "info",
138
204
  );
139
205
  }
@@ -143,14 +209,21 @@ export default function (pi: ExtensionAPI): void {
143
209
  const mode =
144
210
  state.source === "project"
145
211
  ? "project (.pi/openappa)"
146
- : state.source === "env"
212
+ : state.source === "env-on"
147
213
  ? "launch (APPA_GATE=1)"
148
- : state.source === "always-on"
149
- ? "always-on (/appa off to disable)"
150
- : "off — /appa on enables it for every session";
214
+ : state.source === "env-off"
215
+ ? "launch opt-out (APPA_GATE=0)"
216
+ : state.source === "project-off"
217
+ ? "project opt-out (.pi/no-openappa)"
218
+ : state.source === "global-off"
219
+ ? "global opt-out (/appa on re-enables)"
220
+ : "on by default (/appa off disables)";
151
221
  const lines: string[] = [];
152
222
  lines.push(state.gated ? `Protection: ON (session ${sessionId || "not started"})` : `Protection: off — ${mode}`);
153
- if (state.gated) lines.push(`Mode: ${mode}`);
223
+ if (unprotected) {
224
+ lines.push("Session: unprotected — no policy found; see the startup warning.");
225
+ }
226
+ if (state.gated && !unprotected) lines.push(`Mode: ${mode}`);
154
227
  if (state.config !== undefined) lines.push(`Policy: ${state.config}`);
155
228
  lines.push(`Runtime: ${state.runtimeUrl}`);
156
229
  const health = await checkHealth(state.runtimeUrl);
@@ -166,6 +239,13 @@ export default function (pi: ExtensionAPI): void {
166
239
  });
167
240
  }
168
241
 
242
+ /** Last ~200 chars of installer output, whitespace-normalized, for notices. */
243
+ function outcomeTail(outcome: InstallOutcome): string {
244
+ const text = `${outcome.stderr} ${outcome.stdout}`.trim().replace(/\s+/g, " ");
245
+ if (text === "") return "(no output)";
246
+ return text.length > 200 ? `…${text.slice(-200)}` : text;
247
+ }
248
+
169
249
  function joinContent(content: ReadonlyArray<unknown>): string {
170
250
  const parts: string[] = [];
171
251
  for (const item of content) {
package/justfile CHANGED
@@ -30,3 +30,10 @@ remove:
30
30
  # Publish to npm (requires `npm login`; the Pi gallery indexes the pi-package keyword)
31
31
  publish: check
32
32
  npm publish
33
+
34
+ # Ship a version: sync the lockfile version, run every check, publish to npm
35
+ # (run the README smoke test right after)
36
+ deploy:
37
+ npm install --package-lock-only --no-audit --no-fund
38
+ just check
39
+ npm publish
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openappa",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -11,6 +11,13 @@
11
11
  "policy"
12
12
  ],
13
13
  "license": "MIT",
14
+ "author": "aemonge",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/aemonge-dev/pi-openappa.git"
18
+ },
19
+ "homepage": "https://github.com/aemonge-dev/pi-openappa#readme",
20
+ "bugs": "https://github.com/aemonge-dev/pi-openappa/issues",
14
21
  "type": "module",
15
22
  "pi": {
16
23
  "extensions": ["./extensions/index.ts"]
package/src/gate.ts CHANGED
@@ -1,16 +1,20 @@
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 APPA's own default. The gate is
12
14
  * captured once per session so a session cannot disable its own protection
13
15
  * mid-run; `/appa on|off` are deliberate user commands and do re-resolve.
16
+ * The legacy `always-on` marker from opt-in days is ignored; `/appa on`
17
+ * removes it.
14
18
  */
15
19
 
16
20
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -25,7 +29,13 @@ function baseConfigDir(env: NodeJS.ProcessEnv): string {
25
29
  return join(env.HOME ?? "", ".config");
26
30
  }
27
31
 
28
- export function alwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
32
+ /** Global opt-out written by `/appa off`; `/appa on` removes it. */
33
+ export function globalOffMarkerPath(env: NodeJS.ProcessEnv): string {
34
+ return join(baseConfigDir(env), "pi-openappa", "off");
35
+ }
36
+
37
+ /** Legacy opt-in marker from before default-on; ignored, cleared by `/appa on`. */
38
+ export function legacyAlwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
29
39
  return join(baseConfigDir(env), "pi-openappa", "always-on");
30
40
  }
31
41
 
@@ -33,21 +43,45 @@ export function projectMarkerPath(cwd: string): string {
33
43
  return join(cwd, ".pi", "openappa");
34
44
  }
35
45
 
36
- export function isAlwaysOn(env: NodeJS.ProcessEnv): boolean {
37
- return existsSync(alwaysOnMarkerPath(env));
46
+ export function projectNoMarkerPath(cwd: string): string {
47
+ return join(cwd, ".pi", "no-openappa");
48
+ }
49
+
50
+ export function isGloballyOff(env: NodeJS.ProcessEnv): boolean {
51
+ return existsSync(globalOffMarkerPath(env));
38
52
  }
39
53
 
40
- export function setAlwaysOn(env: NodeJS.ProcessEnv, on: boolean): void {
41
- const marker = alwaysOnMarkerPath(env);
42
- if (on) {
54
+ export function setGloballyOff(env: NodeJS.ProcessEnv, off: boolean): void {
55
+ const marker = globalOffMarkerPath(env);
56
+ if (off) {
43
57
  mkdirSync(dirname(marker), { recursive: true });
44
58
  writeFileSync(marker, "");
45
59
  } else {
46
60
  rmSync(marker, { force: true });
61
+ rmSync(legacyAlwaysOnMarkerPath(env), { force: true });
47
62
  }
48
63
  }
49
64
 
50
- export type GateSource = "project" | "env" | "always-on" | "off";
65
+ /**
66
+ * Does APPA's own default policy exist? Heuristic mirror of the runtime's
67
+ * lookup: `$XDG_CONFIG_HOME/appa/appa.toml` or `~/.config/appa/appa.toml`.
68
+ */
69
+ export function appaDefaultPolicyExists(env: NodeJS.ProcessEnv): boolean {
70
+ const xdg = env.XDG_CONFIG_HOME;
71
+ const candidates = [
72
+ ...(xdg !== undefined && xdg !== "" ? [join(xdg, "appa", "appa.toml")] : []),
73
+ join(env.HOME ?? "", ".config", "appa", "appa.toml"),
74
+ ];
75
+ return candidates.some((candidate) => existsSync(candidate));
76
+ }
77
+
78
+ export type GateSource =
79
+ | "env-on"
80
+ | "env-off"
81
+ | "project"
82
+ | "project-off"
83
+ | "global-off"
84
+ | "default";
51
85
 
52
86
  export interface GateState {
53
87
  /** Protection active for this session. */
@@ -83,27 +117,46 @@ export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
83
117
  ? env.APPA_CONFIG
84
118
  : undefined;
85
119
  const projectGated = cwd !== undefined && existsSync(projectMarkerPath(cwd));
120
+ const projectOff = cwd !== undefined && existsSync(projectNoMarkerPath(cwd));
86
121
  const projectCfg =
87
122
  projectGated && cwd !== undefined ? projectConfig(cwd) : undefined;
88
- const config = explicitConfig ?? projectCfg;
89
123
 
90
- if (env.APPA_GATE === "1" || projectGated) {
124
+ // An explicit launch choice beats every marker.
125
+ if (env.APPA_GATE === "1") {
126
+ const config = explicitConfig ?? projectCfg;
91
127
  return {
92
128
  gated: true,
93
- source: projectGated ? "project" : "env",
129
+ source: "env-on",
94
130
  ...base,
95
131
  ...(config !== undefined ? { config } : {}),
96
132
  };
97
133
  }
98
- if (isAlwaysOn(env)) {
134
+ if (env.APPA_GATE === "0") {
135
+ return { gated: false, source: "env-off", ...base };
136
+ }
137
+ // Project level: an opt-out beats the project's own opt-in.
138
+ if (projectOff) {
139
+ return { gated: false, source: "project-off", ...base };
140
+ }
141
+ if (projectGated) {
142
+ const config = explicitConfig ?? projectCfg;
99
143
  return {
100
144
  gated: true,
101
- source: "always-on",
145
+ source: "project",
102
146
  ...base,
103
- ...(explicitConfig !== undefined ? { config: explicitConfig } : {}),
147
+ ...(config !== undefined ? { config } : {}),
104
148
  };
105
149
  }
106
- return { gated: false, source: "off", ...base };
150
+ // Global opt-out, then the default: protection on.
151
+ if (isGloballyOff(env)) {
152
+ return { gated: false, source: "global-off", ...base };
153
+ }
154
+ return {
155
+ gated: true,
156
+ source: "default",
157
+ ...base,
158
+ ...(explicitConfig !== undefined ? { config: explicitConfig } : {}),
159
+ };
107
160
  }
108
161
 
109
162
  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
+ }
@@ -0,0 +1,85 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, describe, it } from "node:test";
3
+ import { sessionStartPayload } from "../src/adapter.ts";
4
+ import { invokeAppaHook } from "../src/hook-client.ts";
5
+ import {
6
+ DEFAULT_INSTALL_CMD,
7
+ installAppa,
8
+ resolveInstallCmd,
9
+ resolveInstallTimeoutMs,
10
+ } from "../src/installer.ts";
11
+
12
+ /** Environment keys this suite mutates; restored after every test. */
13
+ const KEYS = ["APPA_INSTALL_CMD", "APPA_INSTALL_TIMEOUT_MS"];
14
+ const saved: Record<string, string | undefined> = {};
15
+ for (const key of KEYS) saved[key] = process.env[key];
16
+
17
+ after(() => {
18
+ for (const key of KEYS) {
19
+ const value = saved[key];
20
+ if (value === undefined) delete process.env[key];
21
+ else process.env[key] = value;
22
+ }
23
+ });
24
+
25
+ describe("installer resolution", () => {
26
+ it("defaults to the official install pipeline", () => {
27
+ delete process.env.APPA_INSTALL_CMD;
28
+ assert.equal(resolveInstallCmd(process.env), DEFAULT_INSTALL_CMD);
29
+ assert.equal(DEFAULT_INSTALL_CMD, "curl -fsSL https://openappa.com/install.sh | sh");
30
+ });
31
+
32
+ it("honors APPA_INSTALL_CMD", () => {
33
+ process.env.APPA_INSTALL_CMD = "./offline-install.sh";
34
+ assert.equal(resolveInstallCmd(process.env), "./offline-install.sh");
35
+ });
36
+
37
+ it("honors APPA_INSTALL_TIMEOUT_MS with a sane default", () => {
38
+ delete process.env.APPA_INSTALL_TIMEOUT_MS;
39
+ assert.equal(resolveInstallTimeoutMs(process.env), 120_000);
40
+ process.env.APPA_INSTALL_TIMEOUT_MS = "2500";
41
+ assert.equal(resolveInstallTimeoutMs(process.env), 2500);
42
+ process.env.APPA_INSTALL_TIMEOUT_MS = "garbage";
43
+ assert.equal(resolveInstallTimeoutMs(process.env), 120_000);
44
+ });
45
+ });
46
+
47
+ describe("installer run", () => {
48
+ it("captures a successful install", async () => {
49
+ process.env.APPA_INSTALL_CMD = "echo installed-ok";
50
+ const outcome = await installAppa();
51
+ assert.equal(outcome.exitCode, 0);
52
+ assert.ok(outcome.stdout.includes("installed-ok"));
53
+ assert.equal(outcome.timedOut, false);
54
+ });
55
+
56
+ it("captures a failed install with its output", async () => {
57
+ process.env.APPA_INSTALL_CMD = "echo boom >&2; exit 3";
58
+ const outcome = await installAppa();
59
+ assert.equal(outcome.exitCode, 3);
60
+ assert.ok(outcome.stderr.includes("boom"));
61
+ assert.equal(outcome.timedOut, false);
62
+ });
63
+
64
+ it("kills a stuck install", async () => {
65
+ process.env.APPA_INSTALL_CMD = "sleep 5";
66
+ process.env.APPA_INSTALL_TIMEOUT_MS = "150";
67
+ const started = Date.now();
68
+ const outcome = await installAppa();
69
+ assert.equal(outcome.timedOut, true);
70
+ assert.equal(outcome.exitCode, -1);
71
+ assert.ok(outcome.stderr.includes("timed out"));
72
+ assert.ok(Date.now() - started < 4000, "killed promptly, not after sleep 5");
73
+ });
74
+ });
75
+
76
+ describe("hook-client binary detection", () => {
77
+ it("flags a missing hook binary (ENOENT)", async () => {
78
+ const outcome = await invokeAppaHook(sessionStartPayload("s1", "startup", "/tmp"), {
79
+ bin: "/nonexistent-dir/appa",
80
+ });
81
+ assert.equal(outcome.binaryMissing, true);
82
+ assert.equal(outcome.exitCode, -1);
83
+ assert.ok(outcome.stderr.includes("failed to start"));
84
+ });
85
+ });
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, beforeEach, describe, it } from "node:test";
3
- import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync, copyFileSync, chmodSync } from "node:fs";
4
+ import { createServer } from "node:http";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join, dirname } from "node:path";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -10,7 +11,7 @@ const MOCK_BIN = join(here, "fixtures", "mock-appa.mjs");
10
11
  const SESSION = "22222222-2222-2222-2222-222222222222";
11
12
 
12
13
  /** Environment keys this suite mutates; restored after every test. */
13
- const KEYS = ["APPA_GATE", "APPA_HOOK_BIN", "MOCK_MODE", "MOCK_RECORD", "APPA_HOOK_TIMEOUT_MS", "APPA_CONFIG", "XDG_CONFIG_HOME"];
14
+ const KEYS = ["APPA_GATE", "APPA_HOOK_BIN", "MOCK_MODE", "MOCK_RECORD", "APPA_HOOK_TIMEOUT_MS", "APPA_CONFIG", "XDG_CONFIG_HOME", "APPA_INSTALL_CMD", "APPA_INSTALL_TIMEOUT_MS", "PATH", "HOME", "APPA_RUNTIME_URL"];
14
15
  const saved: Record<string, string | undefined> = {};
15
16
  for (const key of KEYS) saved[key] = process.env[key];
16
17
 
@@ -78,6 +79,20 @@ function withMock(dir: string, mode: string): void {
78
79
  process.env.MOCK_RECORD = join(dir, "record.jsonl");
79
80
  }
80
81
 
82
+ /** PATH with `appa` absent: extra dirs first, then only node/curl/sh system dirs. */
83
+ function hermeticPath(...extraDirs: string[]): string {
84
+ return [...extraDirs, dirname(process.execPath), "/usr/bin", "/bin"].join(":");
85
+ }
86
+
87
+ /** Give the fake ctx a recording UI; returns [kind, message] pairs. */
88
+ function enableUiSpy(harness: Harness): Array<[string, string]> {
89
+ const notices: Array<[string, string]> = [];
90
+ const ctx = harness.ctx as { hasUI?: boolean; ui?: { notify: (m: string, k: string) => void } };
91
+ ctx.hasUI = true;
92
+ ctx.ui = { notify: (message, kind) => notices.push([kind, message]) };
93
+ return notices;
94
+ }
95
+
81
96
  const bashCall = { type: "tool_call", toolCallId: "call-1", toolName: "bash", input: { command: "ls", description: "list" } };
82
97
  const bashResult = {
83
98
  type: "tool_result",
@@ -90,16 +105,19 @@ const bashResult = {
90
105
  };
91
106
 
92
107
  beforeEach(() => {
93
- // Isolate gate resolution from real machine state: clean config dir per
94
- // test, no gate env. Tests that need gating set it up inside themselves.
108
+ // Isolate gate resolution from real machine state: clean config dir and
109
+ // HOME per test (no off markers, no APPA default policy), no gate env.
110
+ // Tests that want to opt out set APPA_GATE=0 or a marker themselves.
95
111
  process.env.XDG_CONFIG_HOME = join(workDir(), "xdg");
112
+ process.env.HOME = join(workDir(), "home");
96
113
  delete process.env.APPA_GATE;
97
114
  delete process.env.APPA_CONFIG;
115
+ delete process.env.APPA_RUNTIME_URL;
98
116
  });
99
117
 
100
- describe("gate off: extension is inert", () => {
118
+ describe("opted-out session (APPA_GATE=0): extension is inert", () => {
101
119
  beforeEach(() => {
102
- delete process.env.APPA_GATE;
120
+ process.env.APPA_GATE = "0";
103
121
  });
104
122
 
105
123
  it("never invokes the hook and passes everything through", async () => {
@@ -191,6 +209,10 @@ describe("gated session through the scripted mock", () => {
191
209
  it("fails closed when the hook binary cannot start", async () => {
192
210
  const dir = workDir();
193
211
  withMock(dir, "allow");
212
+ // A named config keeps this fail-closed: with none, a failed start with
213
+ // nothing answering downgrades the session to unprotected instead.
214
+ process.env.APPA_CONFIG = "/tmp/policy.toml";
215
+ process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
194
216
  process.env.APPA_HOOK_BIN = join(dir, "does-not-exist");
195
217
  const harness = await loadExtension();
196
218
  await startSession(harness);
@@ -202,6 +224,8 @@ describe("gated session through the scripted mock", () => {
202
224
  it("fails closed on hook timeout", async () => {
203
225
  const dir = workDir();
204
226
  withMock(dir, "sleep");
227
+ process.env.APPA_CONFIG = "/tmp/policy.toml"; // no no-policy downgrade
228
+ process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
205
229
  process.env.APPA_HOOK_TIMEOUT_MS = "150";
206
230
  const harness = await loadExtension();
207
231
  await startSession(harness);
@@ -315,10 +339,10 @@ describe("auto-start on gated session start", () => {
315
339
  assert.ok(!first?.includes("--config"));
316
340
  });
317
341
 
318
- it("starts nothing when ungated", async () => {
342
+ it("starts nothing when opted out (APPA_GATE=0)", async () => {
319
343
  const dir = workDir();
320
344
  withMock(dir, "allow");
321
- delete process.env.APPA_GATE;
345
+ process.env.APPA_GATE = "0";
322
346
  delete process.env.APPA_CONFIG;
323
347
  const harness = await loadExtension();
324
348
  await startSession(harness);
@@ -327,6 +351,95 @@ describe("auto-start on gated session start", () => {
327
351
  });
328
352
  });
329
353
 
354
+ describe("auto-install on gated session start (default-on)", () => {
355
+ it("installs the default appa and retries ensure-runtime", async () => {
356
+ const dir = workDir();
357
+ const binDir = join(dir, "bin");
358
+ mkdirSync(binDir);
359
+ // No APPA_GATE: protection is on by default now.
360
+ delete process.env.APPA_HOOK_BIN;
361
+ delete process.env.APPA_CONFIG;
362
+ process.env.MOCK_MODE = "allow";
363
+ process.env.MOCK_RECORD = join(dir, "record.jsonl");
364
+ const installed = join(binDir, "appa");
365
+ process.env.APPA_INSTALL_CMD = `cp ${MOCK_BIN} ${installed} && chmod +x ${installed}`;
366
+ process.env.PATH = hermeticPath(binDir);
367
+ const harness = await loadExtension();
368
+ const notices = enableUiSpy(harness);
369
+ await startSession(harness);
370
+ assert.ok(existsSync(installed), "installer ran and placed appa");
371
+ const first = recordedArgv(dir)[0];
372
+ assert.ok(first?.includes("--ensure-runtime"), `argv: ${JSON.stringify(first)}`);
373
+ assert.ok(!first?.includes("--config"));
374
+ assert.ok(notices.some(([kind]) => kind === "info"), "install notice shown");
375
+ assert.deepEqual(
376
+ notices.filter(([kind]) => kind === "warning"),
377
+ [],
378
+ `no warnings on success: ${JSON.stringify(notices)}`,
379
+ );
380
+ });
381
+
382
+ it("reports a failed install once and never retries the hook", async () => {
383
+ const dir = workDir();
384
+ process.env.APPA_GATE = "1";
385
+ delete process.env.APPA_HOOK_BIN;
386
+ delete process.env.APPA_CONFIG;
387
+ process.env.MOCK_MODE = "allow";
388
+ process.env.MOCK_RECORD = join(dir, "record.jsonl");
389
+ process.env.APPA_INSTALL_CMD = "echo boom >&2; exit 3";
390
+ process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
391
+ process.env.PATH = hermeticPath(join(dir, "bin"));
392
+ const harness = await loadExtension();
393
+ const notices = enableUiSpy(harness);
394
+ await startSession(harness);
395
+ assert.deepEqual(recordedArgv(dir), []);
396
+ assert.deepEqual(recordedLines(dir), []);
397
+ const warnings = notices.filter(([kind]) => kind === "warning");
398
+ assert.equal(warnings.length, 1, JSON.stringify(notices));
399
+ assert.ok(warnings[0]?.[1].includes("auto-install failed"));
400
+ assert.ok(warnings[0]?.[1].includes("boom"));
401
+ assert.ok(warnings[0]?.[1].includes("blocked"));
402
+ });
403
+
404
+ it("warns once when the install succeeds but appa is still missing", async () => {
405
+ const dir = workDir();
406
+ process.env.APPA_GATE = "1";
407
+ delete process.env.APPA_HOOK_BIN;
408
+ delete process.env.APPA_CONFIG;
409
+ process.env.MOCK_MODE = "allow";
410
+ process.env.MOCK_RECORD = join(dir, "record.jsonl");
411
+ process.env.APPA_INSTALL_CMD = "true";
412
+ process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
413
+ process.env.PATH = hermeticPath(join(dir, "bin"));
414
+ const harness = await loadExtension();
415
+ const notices = enableUiSpy(harness);
416
+ await startSession(harness);
417
+ assert.deepEqual(recordedArgv(dir), []);
418
+ const warnings = notices.filter(([kind]) => kind === "warning");
419
+ assert.equal(warnings.length, 1, JSON.stringify(notices));
420
+ assert.ok(warnings[0]?.[1].includes("still not on PATH"));
421
+ });
422
+
423
+ it("never auto-installs a custom APPA_HOOK_BIN", async () => {
424
+ const dir = workDir();
425
+ process.env.APPA_GATE = "1";
426
+ process.env.APPA_HOOK_BIN = join(dir, "missing-bin");
427
+ delete process.env.APPA_CONFIG;
428
+ process.env.MOCK_MODE = "allow";
429
+ process.env.MOCK_RECORD = join(dir, "record.jsonl");
430
+ const marker = join(dir, "install-ran");
431
+ process.env.APPA_INSTALL_CMD = `touch ${marker}`;
432
+ process.env.PATH = hermeticPath();
433
+ const harness = await loadExtension();
434
+ const notices = enableUiSpy(harness);
435
+ await startSession(harness);
436
+ assert.ok(!existsSync(marker), "installer must not run for custom binaries");
437
+ const warnings = notices.filter(([kind]) => kind === "warning");
438
+ assert.equal(warnings.length, 1, JSON.stringify(notices));
439
+ assert.ok(warnings[0]?.[1].includes("did not answer"), "generic fail-closed warning");
440
+ });
441
+ });
442
+
330
443
  describe("project-scoped protection (.pi/openappa)", () => {
331
444
  it("gates the project and passes the marker's policy as --config", async () => {
332
445
  const dir = workDir();
@@ -378,14 +491,11 @@ describe("project-scoped protection (.pi/openappa)", () => {
378
491
  });
379
492
  });
380
493
 
381
- describe("always-on mode (marker file)", () => {
382
- it("gates every session once the marker exists", async () => {
494
+ describe("global opt-out (/appa off marker)", () => {
495
+ it("gates every session by default, with no markers or env", async () => {
383
496
  const dir = workDir();
384
497
  withMock(dir, "allow");
385
498
  delete process.env.APPA_GATE;
386
- process.env.XDG_CONFIG_HOME = join(dir, "xdg");
387
- mkdirSync(join(dir, "xdg", "pi-openappa"), { recursive: true });
388
- writeFileSync(join(dir, "xdg", "pi-openappa", "always-on"), "");
389
499
  const harness = await loadExtension();
390
500
  await startSession(harness);
391
501
  const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
@@ -393,11 +503,13 @@ describe("always-on mode (marker file)", () => {
393
503
  assert.ok(recordedArgv(dir).length > 0, "hook was invoked without APPA_GATE");
394
504
  });
395
505
 
396
- it("inert when the marker is absent", async () => {
506
+ it("is inert once the global off marker exists", async () => {
397
507
  const dir = workDir();
398
508
  withMock(dir, "allow");
399
509
  delete process.env.APPA_GATE;
400
- process.env.XDG_CONFIG_HOME = join(dir, "xdg-clean");
510
+ process.env.XDG_CONFIG_HOME = join(dir, "xdg");
511
+ mkdirSync(join(dir, "xdg", "pi-openappa"), { recursive: true });
512
+ writeFileSync(join(dir, "xdg", "pi-openappa", "off"), "");
401
513
  const harness = await loadExtension();
402
514
  await startSession(harness);
403
515
  const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
@@ -407,18 +519,38 @@ describe("always-on mode (marker file)", () => {
407
519
  });
408
520
 
409
521
  describe("/appa on|off", () => {
410
- it("on creates the marker and gates immediately; off removes it", async () => {
522
+ it("off writes the global marker and ungates; on clears it and re-gates", async () => {
411
523
  const dir = workDir();
412
- process.env.XDG_CONFIG_HOME = join(dir, "xdg");
524
+ withMock(dir, "allow");
413
525
  delete process.env.APPA_GATE;
526
+ process.env.XDG_CONFIG_HOME = join(dir, "xdg");
414
527
  const harness = await loadExtension();
415
528
  const command = harness.commands.get("appa");
416
529
  assert.ok(command);
417
- await command.handler("on", harness.ctx);
418
- assert.ok(existsSync(join(dir, "xdg", "pi-openappa", "always-on")));
419
- const gated = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
420
- assert.equal(gated, undefined); // gated now: would invoke hook (mock unset -> inert record empty ok)
421
530
  await command.handler("off", harness.ctx);
531
+ const marker = join(dir, "xdg", "pi-openappa", "off");
532
+ assert.ok(existsSync(marker));
533
+ const argvAfterOff = recordedArgv(dir).length;
534
+ await startSession(harness);
535
+ const optedOut = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
536
+ assert.equal(optedOut, undefined);
537
+ assert.equal(recordedArgv(dir).length, argvAfterOff, "no hook while opted out");
538
+ await command.handler("on", harness.ctx);
539
+ assert.ok(!existsSync(marker));
540
+ await startSession(harness);
541
+ const reGated = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
542
+ assert.equal(reGated, undefined);
543
+ assert.ok(recordedArgv(dir).length > argvAfterOff, "hook invoked again after /appa on");
544
+ });
545
+
546
+ it("on also clears the legacy always-on marker", async () => {
547
+ const dir = workDir();
548
+ process.env.XDG_CONFIG_HOME = join(dir, "xdg");
549
+ mkdirSync(join(dir, "xdg", "pi-openappa"), { recursive: true });
550
+ writeFileSync(join(dir, "xdg", "pi-openappa", "always-on"), "");
551
+ delete process.env.APPA_GATE;
552
+ const harness = await loadExtension();
553
+ await harness.commands.get("appa")!.handler("on", harness.ctx);
422
554
  assert.ok(!existsSync(join(dir, "xdg", "pi-openappa", "always-on")));
423
555
  });
424
556
 
@@ -445,3 +577,91 @@ describe("/appa command", () => {
445
577
  await command.handler("", harness.ctx);
446
578
  });
447
579
  });
580
+
581
+ describe("no-policy downgrade (runs unprotected)", () => {
582
+ function noPolicySetup(dir: string): void {
583
+ delete process.env.APPA_GATE;
584
+ delete process.env.APPA_CONFIG;
585
+ delete process.env.APPA_HOOK_BIN;
586
+ // The mock stands in for the default `appa` binary: present on PATH (so
587
+ // no auto-install triggers), crashing on ensure-runtime (spawn works).
588
+ const binDir = join(dir, "bin");
589
+ mkdirSync(binDir, { recursive: true });
590
+ const asAppa = join(binDir, "appa");
591
+ copyFileSync(MOCK_BIN, asAppa);
592
+ chmodSync(asAppa, 0o755);
593
+ process.env.MOCK_MODE = "crash";
594
+ process.env.MOCK_RECORD = join(dir, "record.jsonl");
595
+ process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799"; // nothing answers
596
+ process.env.APPA_INSTALL_CMD = "exit 1"; // tripwire: must never run
597
+ process.env.PATH = hermeticPath(binDir);
598
+ }
599
+
600
+ it("runs the session unprotected with one warning when no policy exists", async () => {
601
+ const dir = workDir();
602
+ noPolicySetup(dir);
603
+ const harness = await loadExtension();
604
+ const notices = enableUiSpy(harness);
605
+ await startSession(harness);
606
+ const warnings = notices.filter(([kind]) => kind === "warning");
607
+ assert.equal(warnings.length, 1, JSON.stringify(notices));
608
+ assert.ok(warnings[0]?.[1].includes("runs unprotected"));
609
+ // The initial SessionStart went out; tool calls afterwards do not.
610
+ assert.equal(recordedLines(dir).length, 1);
611
+ const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
612
+ assert.equal(call, undefined);
613
+ assert.equal(recordedLines(dir).length, 1, "no hook while unprotected");
614
+ });
615
+
616
+ it("stays fail-closed when APPA's default policy exists", async () => {
617
+ const dir = workDir();
618
+ noPolicySetup(dir);
619
+ mkdirSync(join(process.env.XDG_CONFIG_HOME!, "appa"), { recursive: true });
620
+ writeFileSync(join(process.env.XDG_CONFIG_HOME!, "appa", "appa.toml"), "");
621
+ const harness = await loadExtension();
622
+ const notices = enableUiSpy(harness);
623
+ await startSession(harness);
624
+ const warnings = notices.filter(([kind]) => kind === "warning");
625
+ assert.equal(warnings.length, 1, JSON.stringify(notices));
626
+ assert.ok(warnings[0]?.[1].includes("did not answer"));
627
+ const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
628
+ assert.equal(call.block, true);
629
+ });
630
+
631
+ it("stays fail-closed with a named APPA_CONFIG", async () => {
632
+ const dir = workDir();
633
+ noPolicySetup(dir);
634
+ process.env.APPA_CONFIG = "/tmp/named-but-broken.toml";
635
+ const harness = await loadExtension();
636
+ const notices = enableUiSpy(harness);
637
+ await startSession(harness);
638
+ const warnings = notices.filter(([kind]) => kind === "warning");
639
+ assert.equal(warnings.length, 1, JSON.stringify(notices));
640
+ assert.ok(warnings[0]?.[1].includes("did not answer"));
641
+ const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
642
+ assert.equal(call.block, true);
643
+ });
644
+
645
+ it("stays fail-closed when a runtime is already answering", async () => {
646
+ const dir = workDir();
647
+ noPolicySetup(dir);
648
+ const server = createServer((req, res) => {
649
+ res.end(req.url?.includes("health") ? "ok" : "");
650
+ });
651
+ await new Promise<void>((ready) => server.listen(0, "127.0.0.1", ready));
652
+ const address = server.address() as { port: number };
653
+ process.env.APPA_RUNTIME_URL = `http://127.0.0.1:${address.port}`;
654
+ try {
655
+ const harness = await loadExtension();
656
+ const notices = enableUiSpy(harness);
657
+ await startSession(harness);
658
+ const warnings = notices.filter(([kind]) => kind === "warning");
659
+ assert.equal(warnings.length, 1, JSON.stringify(notices));
660
+ assert.ok(warnings[0]?.[1].includes("did not answer"));
661
+ const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
662
+ assert.equal(call.block, true);
663
+ } finally {
664
+ server.close();
665
+ }
666
+ });
667
+ });
@@ -0,0 +1,131 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, describe, it } from "node:test";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ appaDefaultPolicyExists,
8
+ captureGate,
9
+ globalOffMarkerPath,
10
+ legacyAlwaysOnMarkerPath,
11
+ } from "../src/gate.ts";
12
+
13
+ /** Environment keys this suite mutates; restored after every test. */
14
+ const KEYS = ["APPA_GATE", "APPA_CONFIG", "XDG_CONFIG_HOME", "HOME"];
15
+ const saved: Record<string, string | undefined> = {};
16
+ for (const key of KEYS) saved[key] = process.env[key];
17
+
18
+ after(() => {
19
+ for (const key of KEYS) {
20
+ const value = saved[key];
21
+ if (value === undefined) delete process.env[key];
22
+ else process.env[key] = value;
23
+ }
24
+ });
25
+
26
+ const workDirs: string[] = [];
27
+ after(() => {
28
+ for (const dir of workDirs) rmSync(dir, { recursive: true, force: true });
29
+ });
30
+
31
+ /** Fresh env with isolated HOME/XDG (no markers, no default policy) + project dir. */
32
+ function fresh(): { env: Record<string, string>; proj: string } {
33
+ const dir = mkdtempSync(join(tmpdir(), "pi-openappa-gate-"));
34
+ workDirs.push(dir);
35
+ return {
36
+ env: { HOME: join(dir, "home"), XDG_CONFIG_HOME: join(dir, "xdg") },
37
+ proj: join(dir, "proj"),
38
+ };
39
+ }
40
+
41
+ function projectMarker(env: Record<string, string>, proj: string, content = ""): void {
42
+ mkdirSync(join(proj, ".pi"), { recursive: true });
43
+ writeFileSync(join(proj, ".pi", "openappa"), content);
44
+ }
45
+
46
+ describe("default-on gate resolution", () => {
47
+ it("is gated by default with no markers or env", () => {
48
+ const { env, proj } = fresh();
49
+ const state = captureGate(env, proj);
50
+ assert.equal(state.gated, true);
51
+ assert.equal(state.source, "default");
52
+ assert.equal(state.config, undefined);
53
+ });
54
+
55
+ it("APPA_GATE=0 opts out for one launch; APPA_GATE=1 forces on", () => {
56
+ const { env, proj } = fresh();
57
+ env.APPA_GATE = "0";
58
+ assert.equal(captureGate(env, proj).source, "env-off");
59
+ env.APPA_GATE = "1";
60
+ const on = captureGate(env, proj);
61
+ assert.equal(on.gated, true);
62
+ assert.equal(on.source, "env-on");
63
+ });
64
+
65
+ it("an explicit launch choice beats every project marker", () => {
66
+ const { env, proj } = fresh();
67
+ projectMarker(env, proj, "appa.toml");
68
+ mkdirSync(join(proj, ".pi"), { recursive: true });
69
+ writeFileSync(join(proj, ".pi", "no-openappa"), "");
70
+ env.APPA_GATE = "1";
71
+ assert.equal(captureGate(env, proj).source, "env-on");
72
+ env.APPA_GATE = "0";
73
+ assert.equal(captureGate(env, proj).source, "env-off");
74
+ });
75
+
76
+ it(".pi/openappa gates the project and names its policy", () => {
77
+ const { env, proj } = fresh();
78
+ projectMarker(env, proj, "appa.toml\n");
79
+ const state = captureGate(env, proj);
80
+ assert.equal(state.gated, true);
81
+ assert.equal(state.source, "project");
82
+ assert.equal(state.config, join(proj, "appa.toml"));
83
+ });
84
+
85
+ it(".pi/no-openappa opts the project out, beating .pi/openappa", () => {
86
+ const { env, proj } = fresh();
87
+ projectMarker(env, proj, "appa.toml");
88
+ writeFileSync(join(proj, ".pi", "no-openappa"), "");
89
+ const state = captureGate(env, proj);
90
+ assert.equal(state.gated, false);
91
+ assert.equal(state.source, "project-off");
92
+ });
93
+
94
+ it("the /appa off marker opts out globally; APPA_CONFIG still names a policy", () => {
95
+ const { env, proj } = fresh();
96
+ mkdirSync(join(env.XDG_CONFIG_HOME!, "pi-openappa"), { recursive: true });
97
+ writeFileSync(globalOffMarkerPath(env), "");
98
+ const state = captureGate(env, proj);
99
+ assert.equal(state.gated, false);
100
+ assert.equal(state.source, "global-off");
101
+ env.APPA_CONFIG = "/tmp/policy.toml";
102
+ env.APPA_GATE = "1";
103
+ assert.equal(captureGate(env, proj).config, "/tmp/policy.toml");
104
+ });
105
+
106
+ it("ignores the legacy always-on marker (default is already on)", () => {
107
+ const { env, proj } = fresh();
108
+ mkdirSync(join(env.XDG_CONFIG_HOME!, "pi-openappa"), { recursive: true });
109
+ writeFileSync(legacyAlwaysOnMarkerPath(env), "");
110
+ assert.equal(captureGate(env, proj).source, "default");
111
+ });
112
+ });
113
+
114
+ describe("appaDefaultPolicyExists heuristic", () => {
115
+ it("finds nothing in a clean environment", () => {
116
+ const { env } = fresh();
117
+ assert.equal(appaDefaultPolicyExists(env), false);
118
+ });
119
+
120
+ it("finds ~/.config/appa/appa.toml and the XDG equivalent", () => {
121
+ const home = fresh();
122
+ mkdirSync(join(home.env.HOME!, ".config", "appa"), { recursive: true });
123
+ writeFileSync(join(home.env.HOME!, ".config", "appa", "appa.toml"), "");
124
+ assert.equal(appaDefaultPolicyExists(home.env), true);
125
+
126
+ const xdg = fresh();
127
+ mkdirSync(join(xdg.env.XDG_CONFIG_HOME!, "appa"), { recursive: true });
128
+ writeFileSync(join(xdg.env.XDG_CONFIG_HOME!, "appa", "appa.toml"), "");
129
+ assert.equal(appaDefaultPolicyExists(xdg.env), true);
130
+ });
131
+ });