pi-openappa 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +158 -2
- package/docs/wire-notes.md +80 -0
- package/extensions/index.ts +263 -0
- package/justfile +39 -0
- package/package.json +35 -4
- package/src/adapter.ts +284 -0
- package/src/gate.ts +188 -0
- package/src/hook-client.ts +138 -0
- package/src/installer.ts +103 -0
- package/test/adapter.test.ts +207 -0
- package/test/auto-install.test.ts +85 -0
- package/test/extension.test.ts +667 -0
- package/test/fixtures/mock-appa.mjs +104 -0
- package/test/gate.test.ts +131 -0
- package/tsconfig.json +16 -0
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure translation between host events and the OpenAPPA `appa hook` wire.
|
|
3
|
+
*
|
|
4
|
+
* This module must stay runtime-agnostic: no Pi imports, no I/O. Everything
|
|
5
|
+
* that knows about `appa hook` subprocess semantics lives in hook-client.ts;
|
|
6
|
+
* everything that knows about Pi lives in extensions/index.ts.
|
|
7
|
+
*
|
|
8
|
+
* Wire facts are documented and verified in docs/wire-notes.md (appa 0.31.1).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type HookEventName =
|
|
12
|
+
| "SessionStart"
|
|
13
|
+
| "UserPromptSubmit"
|
|
14
|
+
| "PreToolUse"
|
|
15
|
+
| "PostToolUse"
|
|
16
|
+
| "Stop";
|
|
17
|
+
|
|
18
|
+
export interface AppaHookPayload {
|
|
19
|
+
session_id: string;
|
|
20
|
+
hook_event_name: HookEventName;
|
|
21
|
+
source?: "startup" | "resume";
|
|
22
|
+
prompt?: string;
|
|
23
|
+
tool_name?: string;
|
|
24
|
+
tool_input?: Record<string, unknown>;
|
|
25
|
+
tool_response?: Record<string, unknown>;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Decision for a tool call (PreToolUse). */
|
|
30
|
+
export type AppaCallDecision =
|
|
31
|
+
| { type: "allow" }
|
|
32
|
+
| { type: "deny"; reason: string };
|
|
33
|
+
|
|
34
|
+
/** Decision for a tool result (PostToolUse). */
|
|
35
|
+
export type AppaResultDecision =
|
|
36
|
+
| { type: "pass" }
|
|
37
|
+
| { type: "replace"; text: string; isError: boolean };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pi built-in tool names → Claude Code policy names. The claude-code codec
|
|
41
|
+
* and battery argument selectors are written against these; custom tools pass
|
|
42
|
+
* through verbatim and policies declare them under the host name.
|
|
43
|
+
*/
|
|
44
|
+
const TOOL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
45
|
+
bash: "Bash",
|
|
46
|
+
powershell: "PowerShell",
|
|
47
|
+
read: "Read",
|
|
48
|
+
edit: "Edit",
|
|
49
|
+
write: "Write",
|
|
50
|
+
grep: "Grep",
|
|
51
|
+
find: "Glob",
|
|
52
|
+
ls: "LS",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function mapToolName(piName: string): string {
|
|
56
|
+
return TOOL_NAME_MAP[piName] ?? piName;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** pi session_start reason → Claude Code SessionStart source. */
|
|
60
|
+
export function mapSessionSource(
|
|
61
|
+
reason: "startup" | "reload" | "new" | "resume" | "fork",
|
|
62
|
+
): "startup" | "resume" {
|
|
63
|
+
return reason === "resume" || reason === "fork" || reason === "reload"
|
|
64
|
+
? "resume"
|
|
65
|
+
: "startup";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function sessionStartPayload(
|
|
69
|
+
sessionId: string,
|
|
70
|
+
reason: "startup" | "reload" | "new" | "resume" | "fork",
|
|
71
|
+
cwd: string,
|
|
72
|
+
): AppaHookPayload {
|
|
73
|
+
return {
|
|
74
|
+
session_id: sessionId,
|
|
75
|
+
hook_event_name: "SessionStart",
|
|
76
|
+
source: mapSessionSource(reason),
|
|
77
|
+
cwd,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function promptPayload(
|
|
82
|
+
sessionId: string,
|
|
83
|
+
prompt: string,
|
|
84
|
+
cwd: string,
|
|
85
|
+
): AppaHookPayload {
|
|
86
|
+
return {
|
|
87
|
+
session_id: sessionId,
|
|
88
|
+
hook_event_name: "UserPromptSubmit",
|
|
89
|
+
prompt,
|
|
90
|
+
cwd,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the PreToolUse payload. The returned payload's `tool_input` reference
|
|
96
|
+
* must be echoed byte-identically at PostToolUse time: the runtime digests it
|
|
97
|
+
* canonically and withholds results that mismatch (byte_mismatch). Callers
|
|
98
|
+
* must keep the exact `input` object they passed here until the call settles.
|
|
99
|
+
*/
|
|
100
|
+
export function preToolUsePayload(
|
|
101
|
+
sessionId: string,
|
|
102
|
+
piToolName: string,
|
|
103
|
+
input: Record<string, unknown>,
|
|
104
|
+
cwd: string,
|
|
105
|
+
): AppaHookPayload {
|
|
106
|
+
return {
|
|
107
|
+
session_id: sessionId,
|
|
108
|
+
hook_event_name: "PreToolUse",
|
|
109
|
+
tool_name: mapToolName(piToolName),
|
|
110
|
+
tool_input: input,
|
|
111
|
+
cwd,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the PostToolUse payload. `input` MUST be the exact object previously
|
|
117
|
+
* passed to preToolUsePayload for this call (see its doc comment).
|
|
118
|
+
*/
|
|
119
|
+
export function postToolUsePayload(
|
|
120
|
+
sessionId: string,
|
|
121
|
+
piToolName: string,
|
|
122
|
+
input: Record<string, unknown>,
|
|
123
|
+
response: Record<string, unknown>,
|
|
124
|
+
cwd: string,
|
|
125
|
+
): AppaHookPayload {
|
|
126
|
+
return {
|
|
127
|
+
session_id: sessionId,
|
|
128
|
+
hook_event_name: "PostToolUse",
|
|
129
|
+
tool_name: mapToolName(piToolName),
|
|
130
|
+
tool_input: input,
|
|
131
|
+
tool_response: response,
|
|
132
|
+
cwd,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function stopPayload(sessionId: string): AppaHookPayload {
|
|
137
|
+
return { session_id: sessionId, hook_event_name: "Stop" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Host tool result → Claude-Code-shaped `tool_response`. Structured fields are
|
|
142
|
+
* copied when the host provides them; every result also carries the joined
|
|
143
|
+
* text and error flag so unknown tools stay observable.
|
|
144
|
+
*/
|
|
145
|
+
export function toolResponseFrom(parts: {
|
|
146
|
+
text?: string;
|
|
147
|
+
isError?: boolean;
|
|
148
|
+
details?: unknown;
|
|
149
|
+
}): Record<string, unknown> {
|
|
150
|
+
const response: Record<string, unknown> = {
|
|
151
|
+
output: parts.text ?? "",
|
|
152
|
+
isError: parts.isError === true,
|
|
153
|
+
};
|
|
154
|
+
if (isRecord(parts.details)) {
|
|
155
|
+
for (const key of ["stdout", "stderr", "exitcode", "interrupted"] as const) {
|
|
156
|
+
const value = parts.details[key];
|
|
157
|
+
if (value !== undefined) response[key] = value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return response;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Render a runtime-supplied `updatedToolOutput` as model-facing text. */
|
|
164
|
+
export function renderToolOutput(output: Record<string, unknown>): string {
|
|
165
|
+
const stdout = typeof output.stdout === "string" ? output.stdout : undefined;
|
|
166
|
+
const stderr = typeof output.stderr === "string" ? output.stderr : undefined;
|
|
167
|
+
if (stdout !== undefined || stderr !== undefined) {
|
|
168
|
+
const parts: string[] = [];
|
|
169
|
+
if (stdout !== undefined && stdout !== "") parts.push(stdout);
|
|
170
|
+
if (stderr !== undefined && stderr !== "") parts.push(stderr);
|
|
171
|
+
if (parts.length > 0) return parts.join("\n");
|
|
172
|
+
}
|
|
173
|
+
return JSON.stringify(output, null, 2);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const BLOCKED_PREFIX = "OpenAPPA hook blocked: ";
|
|
177
|
+
|
|
178
|
+
function reasonFromStderr(stderr: string): string {
|
|
179
|
+
const trimmed = stderr.trim();
|
|
180
|
+
if (trimmed.startsWith(BLOCKED_PREFIX)) {
|
|
181
|
+
return trimmed.slice(BLOCKED_PREFIX.length);
|
|
182
|
+
}
|
|
183
|
+
return trimmed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface StdoutDecision {
|
|
187
|
+
permissionDecision?: string;
|
|
188
|
+
permissionDecisionReason?: string;
|
|
189
|
+
decision?: string;
|
|
190
|
+
reason?: string;
|
|
191
|
+
error?: string;
|
|
192
|
+
hookSpecificOutput?: {
|
|
193
|
+
hookEventName?: string;
|
|
194
|
+
permissionDecision?: string;
|
|
195
|
+
permissionDecisionReason?: string;
|
|
196
|
+
updatedToolOutput?: Record<string, unknown>;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseStdout(stdout: string): StdoutDecision | undefined {
|
|
201
|
+
const text = stdout.trim();
|
|
202
|
+
if (text === "" || text === "{}") return undefined;
|
|
203
|
+
try {
|
|
204
|
+
const parsed: unknown = JSON.parse(text);
|
|
205
|
+
return isRecord(parsed) ? (parsed as StdoutDecision) : undefined;
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
212
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Map a finished `appa hook` invocation for a PreToolUse event to a call
|
|
217
|
+
* decision. Fail-closed: every unrecognized outcome denies.
|
|
218
|
+
*/
|
|
219
|
+
export function parseCallDecision(
|
|
220
|
+
exitCode: number,
|
|
221
|
+
stdout: string,
|
|
222
|
+
stderr: string,
|
|
223
|
+
): AppaCallDecision {
|
|
224
|
+
const out = parseStdout(stdout);
|
|
225
|
+
if (exitCode === 0 && out === undefined) return { type: "allow" };
|
|
226
|
+
if (out?.error !== undefined && exitCode !== 0) {
|
|
227
|
+
return { type: "deny", reason: String(out.error) };
|
|
228
|
+
}
|
|
229
|
+
const permission = out?.hookSpecificOutput?.permissionDecision ?? out?.permissionDecision;
|
|
230
|
+
if (permission === "deny") {
|
|
231
|
+
return {
|
|
232
|
+
type: "deny",
|
|
233
|
+
reason:
|
|
234
|
+
out?.hookSpecificOutput?.permissionDecisionReason ??
|
|
235
|
+
out?.permissionDecisionReason ??
|
|
236
|
+
out?.reason ??
|
|
237
|
+
"denied by APPA policy",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (out?.decision === "block" || out?.decision === "deny_call") {
|
|
241
|
+
return { type: "deny", reason: out?.reason ?? "blocked by APPA policy" };
|
|
242
|
+
}
|
|
243
|
+
if (exitCode === 0 && (permission === "allow" || permission === undefined)) {
|
|
244
|
+
if (out?.decision !== undefined && out.decision !== "allow") {
|
|
245
|
+
// A decision we do not understand is treated as a denial, not a pass.
|
|
246
|
+
return {
|
|
247
|
+
type: "deny",
|
|
248
|
+
reason: out?.reason ?? `unrecognized APPA decision: ${out.decision}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return { type: "allow" };
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
type: "deny",
|
|
255
|
+
reason: reasonFromStderr(stderr) || `appa hook exited ${exitCode}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Map a finished `appa hook` invocation for a PostToolUse event to a result
|
|
261
|
+
* decision. The runtime replaces results through `updatedToolOutput`; exit 2
|
|
262
|
+
* or an unreadable outcome withholds the result (isError) rather than passing
|
|
263
|
+
* possibly-unauthorized content through.
|
|
264
|
+
*/
|
|
265
|
+
export function parseResultDecision(
|
|
266
|
+
exitCode: number,
|
|
267
|
+
stdout: string,
|
|
268
|
+
stderr: string,
|
|
269
|
+
): AppaResultDecision {
|
|
270
|
+
const out = parseStdout(stdout);
|
|
271
|
+
const updated = out?.hookSpecificOutput?.updatedToolOutput;
|
|
272
|
+
if (exitCode === 0 && isRecord(updated)) {
|
|
273
|
+
return { type: "replace", text: renderToolOutput(updated), isError: false };
|
|
274
|
+
}
|
|
275
|
+
if (exitCode === 0 && (out === undefined || out.decision === undefined || out.decision === "allow")) {
|
|
276
|
+
return { type: "pass" };
|
|
277
|
+
}
|
|
278
|
+
const reason =
|
|
279
|
+
out?.reason ??
|
|
280
|
+
(out?.error !== undefined ? String(out.error) : undefined) ??
|
|
281
|
+
reasonFromStderr(stderr) ??
|
|
282
|
+
(exitCode === 0 ? "tool result withheld by APPA policy" : `appa hook exited ${exitCode}`);
|
|
283
|
+
return { type: "replace", text: reason, isError: true };
|
|
284
|
+
}
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session gate: protection is ON by default and fixed at session start.
|
|
3
|
+
*
|
|
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).
|
|
11
|
+
*
|
|
12
|
+
* An explicit APPA_CONFIG always wins as the policy source; otherwise a
|
|
13
|
+
* project marker's content is used; otherwise APPA's own default. The gate is
|
|
14
|
+
* captured once per session so a session cannot disable its own protection
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_RUNTIME_URL = "http://127.0.0.1:8787";
|
|
24
|
+
|
|
25
|
+
/** Base config directory honoring XDG_CONFIG_HOME, falling back to ~/.config. */
|
|
26
|
+
function baseConfigDir(env: NodeJS.ProcessEnv): string {
|
|
27
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
28
|
+
if (xdg !== undefined && xdg !== "") return xdg;
|
|
29
|
+
return join(env.HOME ?? "", ".config");
|
|
30
|
+
}
|
|
31
|
+
|
|
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 {
|
|
39
|
+
return join(baseConfigDir(env), "pi-openappa", "always-on");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function projectMarkerPath(cwd: string): string {
|
|
43
|
+
return join(cwd, ".pi", "openappa");
|
|
44
|
+
}
|
|
45
|
+
|
|
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));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setGloballyOff(env: NodeJS.ProcessEnv, off: boolean): void {
|
|
55
|
+
const marker = globalOffMarkerPath(env);
|
|
56
|
+
if (off) {
|
|
57
|
+
mkdirSync(dirname(marker), { recursive: true });
|
|
58
|
+
writeFileSync(marker, "");
|
|
59
|
+
} else {
|
|
60
|
+
rmSync(marker, { force: true });
|
|
61
|
+
rmSync(legacyAlwaysOnMarkerPath(env), { force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
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";
|
|
85
|
+
|
|
86
|
+
export interface GateState {
|
|
87
|
+
/** Protection active for this session. */
|
|
88
|
+
gated: boolean;
|
|
89
|
+
/** Most specific reason this session is (or is not) protected. */
|
|
90
|
+
source: GateSource;
|
|
91
|
+
/** Policy for auto-start: explicit env wins, then project marker content. */
|
|
92
|
+
config?: string;
|
|
93
|
+
/** Runtime URL for health reporting (the hook binary reads it from env). */
|
|
94
|
+
runtimeUrl: string;
|
|
95
|
+
/** Hook binary used for reporting. */
|
|
96
|
+
hookBin: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read a project marker's optional policy path; empty content resolves to none. */
|
|
100
|
+
function projectConfig(cwd: string): string | undefined {
|
|
101
|
+
try {
|
|
102
|
+
const content = readFileSync(projectMarkerPath(cwd), "utf8").trim();
|
|
103
|
+
if (content === "") return undefined;
|
|
104
|
+
return isAbsolute(content) ? content : resolve(cwd, content);
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
|
|
111
|
+
const base = {
|
|
112
|
+
runtimeUrl: env.APPA_RUNTIME_URL ?? DEFAULT_RUNTIME_URL,
|
|
113
|
+
hookBin: env.APPA_HOOK_BIN ?? "appa",
|
|
114
|
+
};
|
|
115
|
+
const explicitConfig =
|
|
116
|
+
env.APPA_CONFIG !== undefined && env.APPA_CONFIG !== ""
|
|
117
|
+
? env.APPA_CONFIG
|
|
118
|
+
: undefined;
|
|
119
|
+
const projectGated = cwd !== undefined && existsSync(projectMarkerPath(cwd));
|
|
120
|
+
const projectOff = cwd !== undefined && existsSync(projectNoMarkerPath(cwd));
|
|
121
|
+
const projectCfg =
|
|
122
|
+
projectGated && cwd !== undefined ? projectConfig(cwd) : undefined;
|
|
123
|
+
|
|
124
|
+
// An explicit launch choice beats every marker.
|
|
125
|
+
if (env.APPA_GATE === "1") {
|
|
126
|
+
const config = explicitConfig ?? projectCfg;
|
|
127
|
+
return {
|
|
128
|
+
gated: true,
|
|
129
|
+
source: "env-on",
|
|
130
|
+
...base,
|
|
131
|
+
...(config !== undefined ? { config } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
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;
|
|
143
|
+
return {
|
|
144
|
+
gated: true,
|
|
145
|
+
source: "project",
|
|
146
|
+
...base,
|
|
147
|
+
...(config !== undefined ? { config } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
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
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface HealthResult {
|
|
163
|
+
ok: boolean;
|
|
164
|
+
detail: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function checkHealth(
|
|
168
|
+
runtimeUrl: string,
|
|
169
|
+
timeoutMs = 2000,
|
|
170
|
+
): Promise<HealthResult> {
|
|
171
|
+
try {
|
|
172
|
+
const response = await fetch(new URL("health", withSlash(runtimeUrl)), {
|
|
173
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
174
|
+
});
|
|
175
|
+
const body = (await response.text()).trim();
|
|
176
|
+
if (response.ok && body === "ok") {
|
|
177
|
+
return { ok: true, detail: "ok" };
|
|
178
|
+
}
|
|
179
|
+
return { ok: false, detail: body !== "" ? body : `HTTP ${response.status}` };
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
182
|
+
return { ok: false, detail };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function withSlash(url: string): string {
|
|
187
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
188
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only module that talks to OpenAPPA: it runs `appa hook` and returns the
|
|
3
|
+
* raw outcome. It never decides — adapter.ts interprets, extensions wire.
|
|
4
|
+
*
|
|
5
|
+
* Design rules:
|
|
6
|
+
* - Never throw for policy or transport outcomes; every failure becomes a
|
|
7
|
+
* HookOutcome the decision parsers treat as fail-closed.
|
|
8
|
+
* - Gate and URL come from the inherited environment (APPA_GATE is checked by
|
|
9
|
+
* the gate module before any invocation happens; APPA_RUNTIME_URL and other
|
|
10
|
+
* APPA_* conventions flow straight through to the binary).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import type { AppaHookPayload } from "./adapter.ts";
|
|
15
|
+
|
|
16
|
+
export interface HookOutcome {
|
|
17
|
+
exitCode: number;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
timedOut: boolean;
|
|
21
|
+
/** The hook binary itself could not be spawned (ENOENT). */
|
|
22
|
+
binaryMissing: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InvokeOptions {
|
|
26
|
+
/** Report a finished turn (non-blocking `--turn-end`). */
|
|
27
|
+
turnEnd?: boolean;
|
|
28
|
+
/** Bring the runtime up before posting (SessionStart semantics). */
|
|
29
|
+
ensureRuntime?: boolean;
|
|
30
|
+
/** Config the started runtime serves; requires ensureRuntime. */
|
|
31
|
+
config?: string;
|
|
32
|
+
/** Override for the `appa` binary; defaults to APPA_HOOK_BIN or "appa". */
|
|
33
|
+
bin?: string;
|
|
34
|
+
/** Kill the hook after this many ms; defaults to APPA_HOOK_TIMEOUT_MS or 15000. */
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
39
|
+
|
|
40
|
+
export function resolveHookBin(env: NodeJS.ProcessEnv): string {
|
|
41
|
+
return env.APPA_HOOK_BIN ?? "appa";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveTimeoutMs(env: NodeJS.ProcessEnv): number {
|
|
45
|
+
const raw = Number(env.APPA_HOOK_TIMEOUT_MS);
|
|
46
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function invokeAppaHook(
|
|
50
|
+
payload: AppaHookPayload,
|
|
51
|
+
options: InvokeOptions = {},
|
|
52
|
+
): Promise<HookOutcome> {
|
|
53
|
+
const bin = options.bin ?? resolveHookBin(process.env);
|
|
54
|
+
const timeoutMs = options.timeoutMs ?? resolveTimeoutMs(process.env);
|
|
55
|
+
const args = ["hook"];
|
|
56
|
+
if (options.turnEnd === true) args.push("--turn-end");
|
|
57
|
+
if (options.ensureRuntime === true) args.push("--ensure-runtime");
|
|
58
|
+
if (options.config !== undefined) args.push("--config", options.config);
|
|
59
|
+
|
|
60
|
+
return await new Promise<HookOutcome>((resolve) => {
|
|
61
|
+
let child;
|
|
62
|
+
try {
|
|
63
|
+
child = spawn(bin, args, {
|
|
64
|
+
env: { ...process.env, APPA_GATE: "1" },
|
|
65
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
66
|
+
});
|
|
67
|
+
} catch (error) {
|
|
68
|
+
resolve(spawnFailure(error));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let stdout = "";
|
|
73
|
+
let stderr = "";
|
|
74
|
+
let timedOut = false;
|
|
75
|
+
let settled = false;
|
|
76
|
+
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
timedOut = true;
|
|
79
|
+
child.kill("SIGKILL");
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
|
|
82
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
83
|
+
stdout += chunk.toString("utf8");
|
|
84
|
+
});
|
|
85
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
86
|
+
stderr += chunk.toString("utf8");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const finish = (exitCode: number) => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
if (timedOut) {
|
|
94
|
+
resolve({
|
|
95
|
+
exitCode: -1,
|
|
96
|
+
stdout,
|
|
97
|
+
stderr: `${stderr}appa hook timed out after ${timeoutMs}ms`.trim(),
|
|
98
|
+
timedOut: true,
|
|
99
|
+
binaryMissing: false,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
resolve({ exitCode, stdout, stderr, timedOut: false, binaryMissing: false });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
child.on("error", (error) => {
|
|
107
|
+
resolve(mergeOutcome(spawnFailure(error), stderr));
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
});
|
|
111
|
+
child.on("close", (code) => finish(code ?? -1));
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
child.stdin?.end(JSON.stringify(payload));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
child.kill("SIGKILL");
|
|
117
|
+
resolve(mergeOutcome(spawnFailure(error), stderr));
|
|
118
|
+
settled = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function spawnFailure(error: unknown): HookOutcome {
|
|
125
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
126
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
127
|
+
return {
|
|
128
|
+
exitCode: -1,
|
|
129
|
+
stdout: "",
|
|
130
|
+
stderr: `appa hook failed to start: ${detail}`,
|
|
131
|
+
timedOut: false,
|
|
132
|
+
binaryMissing: code === "ENOENT",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mergeOutcome(base: HookOutcome, stderrSoFar: string): HookOutcome {
|
|
137
|
+
return { ...base, stderr: `${stderrSoFar}${base.stderr}`.trim() };
|
|
138
|
+
}
|
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
|
+
}
|