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 +61 -23
- package/extensions/index.ts +101 -21
- package/justfile +7 -0
- package/package.json +8 -1
- package/src/gate.ts +73 -20
- package/src/hook-client.ts +6 -1
- package/src/installer.ts +103 -0
- package/test/auto-install.test.ts +85 -0
- package/test/extension.test.ts +241 -21
- package/test/gate.test.ts +131 -0
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`
|
|
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
|
|
|
22
23
|
## Install
|
|
23
24
|
|
|
24
25
|
```sh
|
|
25
|
-
pi install npm:pi-openappa #
|
|
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
|
|
47
|
+
Protection is **on by default**: every Pi session is guarded unless you opt
|
|
48
|
+
out. Opt-outs, most specific first:
|
|
32
49
|
|
|
33
|
-
- **
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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`
|
|
63
|
-
| `.pi/openappa` | absent | Project marker:
|
|
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,
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
-
|
|
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`
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
56
|
+
if (!gate.gated) return;
|
|
57
|
+
unprotected = false;
|
|
48
58
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
136
|
-
: "OpenAPPA
|
|
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 === "
|
|
149
|
-
? "
|
|
150
|
-
:
|
|
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 (
|
|
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.
|
|
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
|
|
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 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
|
-
|
|
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
|
|
37
|
-
return
|
|
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
|
|
41
|
-
const marker =
|
|
42
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
129
|
+
source: "env-on",
|
|
94
130
|
...base,
|
|
95
131
|
...(config !== undefined ? { config } : {}),
|
|
96
132
|
};
|
|
97
133
|
}
|
|
98
|
-
if (
|
|
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: "
|
|
145
|
+
source: "project",
|
|
102
146
|
...base,
|
|
103
|
-
...(
|
|
147
|
+
...(config !== undefined ? { config } : {}),
|
|
104
148
|
};
|
|
105
149
|
}
|
|
106
|
-
|
|
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 {
|
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
|
+
}
|
|
@@ -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
|
+
});
|
package/test/extension.test.ts
CHANGED
|
@@ -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
|
|
94
|
-
// test
|
|
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("
|
|
118
|
+
describe("opted-out session (APPA_GATE=0): extension is inert", () => {
|
|
101
119
|
beforeEach(() => {
|
|
102
|
-
|
|
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
|
|
342
|
+
it("starts nothing when opted out (APPA_GATE=0)", async () => {
|
|
319
343
|
const dir = workDir();
|
|
320
344
|
withMock(dir, "allow");
|
|
321
|
-
|
|
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("
|
|
382
|
-
it("gates every session
|
|
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
|
|
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
|
|
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("
|
|
522
|
+
it("off writes the global marker and ungates; on clears it and re-gates", async () => {
|
|
411
523
|
const dir = workDir();
|
|
412
|
-
|
|
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
|
+
});
|