@openape/apes 0.6.1 → 0.7.2
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 +68 -6
- package/dist/auth-lock-SRUFWJC3.js +41 -0
- package/dist/auth-lock-SRUFWJC3.js.map +1 -0
- package/dist/{chunk-G3Q2TMAI.js → chunk-B32ZQP5K.js} +139 -190
- package/dist/chunk-B32ZQP5K.js.map +1 -0
- package/dist/{chunk-KVBHBOED.js → chunk-ION3CWD5.js} +14 -2
- package/dist/chunk-ION3CWD5.js.map +1 -0
- package/dist/chunk-TBYYREL6.js +133 -0
- package/dist/chunk-TBYYREL6.js.map +1 -0
- package/dist/cli.js +349 -213
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +11 -9
- package/dist/orchestrator-JAMWD6DD.js +637 -0
- package/dist/orchestrator-JAMWD6DD.js.map +1 -0
- package/dist/{server-FR6GFS3S.js → server-GPETT3ON.js} +8 -6
- package/dist/{server-FR6GFS3S.js.map → server-GPETT3ON.js.map} +1 -1
- package/dist/ssh-key-YBNNG5K5.js +10 -0
- package/package.json +5 -2
- package/scripts/ape-shell-wrapper.sh +103 -0
- package/scripts/fix-node-pty-perms.mjs +39 -0
- package/dist/chunk-G3Q2TMAI.js.map +0 -1
- package/dist/chunk-KVBHBOED.js.map +0 -1
- package/dist/ssh-key-Q7KG4K25.js +0 -8
- /package/dist/{ssh-key-Q7KG4K25.js.map → ssh-key-YBNNG5K5.js.map} +0 -0
package/dist/index.d.ts
CHANGED
|
@@ -171,6 +171,10 @@ declare function createShapesGrant(resolved: ResolvedCommand, params: {
|
|
|
171
171
|
}>;
|
|
172
172
|
declare function waitForGrantStatus(idp: string, grantId: string): Promise<'approved' | 'denied' | 'revoked'>;
|
|
173
173
|
declare function fetchGrantToken(idp: string, grantId: string): Promise<string>;
|
|
174
|
+
/**
|
|
175
|
+
* One-shot verify + consume + execute. Preserves the legacy behavior of
|
|
176
|
+
* the `apes run --shell` path so existing callers keep working unchanged.
|
|
177
|
+
*/
|
|
174
178
|
declare function verifyAndExecute(token: string, resolved: ResolvedCommand): Promise<void>;
|
|
175
179
|
declare function findExistingGrant(resolved: ResolvedCommand, idp: string): Promise<string | null>;
|
|
176
180
|
|
|
@@ -183,7 +187,12 @@ declare function buildStructuredCliGrantRequest(resolved: ResolvedCommand | Reso
|
|
|
183
187
|
|
|
184
188
|
declare function fetchRegistry(forceRefresh?: boolean): Promise<RegistryIndex>;
|
|
185
189
|
declare function searchAdapters(index: RegistryIndex, query: string): RegistryEntry[];
|
|
186
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Look up a registry entry by its id or its executable field. This lets callers
|
|
192
|
+
* pass either the registry id ("o365") or the binary name ("o365-cli"); most
|
|
193
|
+
* adapters have id === executable, but the two can diverge.
|
|
194
|
+
*/
|
|
195
|
+
declare function findAdapter(index: RegistryIndex, idOrExecutable: string): RegistryEntry | undefined;
|
|
187
196
|
|
|
188
197
|
interface InstallResult {
|
|
189
198
|
id: string;
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
appendAuditLog,
|
|
11
11
|
buildExactCommandGrantRequest,
|
|
12
12
|
buildStructuredCliGrantRequest,
|
|
13
|
-
clearAuth,
|
|
14
13
|
createShapesGrant,
|
|
15
14
|
discoverEndpoints,
|
|
16
15
|
extractOption,
|
|
@@ -21,28 +20,31 @@ import {
|
|
|
21
20
|
findAdapter,
|
|
22
21
|
findConflictingAdapters,
|
|
23
22
|
findExistingGrant,
|
|
24
|
-
getAuthToken,
|
|
25
|
-
getIdpUrl,
|
|
26
23
|
getInstalledDigest,
|
|
27
|
-
getRequesterIdentity,
|
|
28
24
|
installAdapter,
|
|
29
25
|
isInstalled,
|
|
30
26
|
loadAdapter,
|
|
31
|
-
loadAuth,
|
|
32
|
-
loadConfig,
|
|
33
27
|
loadOrInstallAdapter,
|
|
34
28
|
parseShellCommand,
|
|
35
29
|
removeAdapter,
|
|
36
30
|
resolveAdapterPath,
|
|
37
31
|
resolveCapabilityRequest,
|
|
38
32
|
resolveCommand,
|
|
39
|
-
saveAuth,
|
|
40
|
-
saveConfig,
|
|
41
33
|
searchAdapters,
|
|
42
34
|
tryLoadAdapter,
|
|
43
35
|
verifyAndExecute,
|
|
44
36
|
waitForGrantStatus
|
|
45
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-B32ZQP5K.js";
|
|
38
|
+
import {
|
|
39
|
+
clearAuth,
|
|
40
|
+
getAuthToken,
|
|
41
|
+
getIdpUrl,
|
|
42
|
+
getRequesterIdentity,
|
|
43
|
+
loadAuth,
|
|
44
|
+
loadConfig,
|
|
45
|
+
saveAuth,
|
|
46
|
+
saveConfig
|
|
47
|
+
} from "./chunk-TBYYREL6.js";
|
|
46
48
|
export {
|
|
47
49
|
ApiError,
|
|
48
50
|
CliError,
|
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
apiFetch,
|
|
4
|
+
appendAuditLog,
|
|
5
|
+
createShapesGrant,
|
|
6
|
+
fetchGrantToken,
|
|
7
|
+
findExistingGrant,
|
|
8
|
+
getGrantsEndpoint,
|
|
9
|
+
loadOrInstallAdapter,
|
|
10
|
+
parseShellCommand,
|
|
11
|
+
resolveCommand,
|
|
12
|
+
verifyAndConsume,
|
|
13
|
+
waitForGrantStatus
|
|
14
|
+
} from "./chunk-B32ZQP5K.js";
|
|
15
|
+
import {
|
|
16
|
+
loadAuth
|
|
17
|
+
} from "./chunk-TBYYREL6.js";
|
|
18
|
+
|
|
19
|
+
// src/shell/orchestrator.ts
|
|
20
|
+
import { hostname } from "os";
|
|
21
|
+
import consola3 from "consola";
|
|
22
|
+
|
|
23
|
+
// src/shell/grant-dispatch.ts
|
|
24
|
+
import { basename } from "path";
|
|
25
|
+
import consola from "consola";
|
|
26
|
+
async function requestGrantForShellLine(line, options) {
|
|
27
|
+
const auth = loadAuth();
|
|
28
|
+
if (!auth) {
|
|
29
|
+
return { kind: "denied", reason: "Not logged in. Run `apes login` first." };
|
|
30
|
+
}
|
|
31
|
+
const idp = auth.idp;
|
|
32
|
+
if (!idp) {
|
|
33
|
+
return { kind: "denied", reason: "No IdP URL configured. Run `apes login` first." };
|
|
34
|
+
}
|
|
35
|
+
const parsed = parseShellCommand(line);
|
|
36
|
+
if (parsed && !parsed.isCompound) {
|
|
37
|
+
try {
|
|
38
|
+
const loaded = await loadOrInstallAdapter(parsed.executable);
|
|
39
|
+
if (loaded) {
|
|
40
|
+
const normalizedExecutable = basename(parsed.executable);
|
|
41
|
+
const resolved = await resolveCommand(loaded, [normalizedExecutable, ...parsed.argv]);
|
|
42
|
+
try {
|
|
43
|
+
const existingGrantId = await findExistingGrant(resolved, idp);
|
|
44
|
+
if (existingGrantId) {
|
|
45
|
+
consola.info(`Reusing grant ${existingGrantId} for: ${resolved.detail.display}`);
|
|
46
|
+
const token2 = await fetchGrantToken(idp, existingGrantId);
|
|
47
|
+
await verifyAndConsume(token2, resolved);
|
|
48
|
+
return { kind: "approved", grantId: existingGrantId, mode: "adapter" };
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
consola.debug(`ape-shell: findExistingGrant failed, will request new grant:`, err);
|
|
52
|
+
}
|
|
53
|
+
consola.info(`Requesting grant for: ${resolved.detail.display}`);
|
|
54
|
+
const grant = await createShapesGrant(resolved, {
|
|
55
|
+
idp,
|
|
56
|
+
approval: options.approval ?? "once",
|
|
57
|
+
reason: `ape-shell: ${resolved.detail.display}`
|
|
58
|
+
});
|
|
59
|
+
consola.info(`Approve at: ${idp}/grant-approval?grant_id=${grant.id}`);
|
|
60
|
+
const status = await waitForGrantStatus(idp, grant.id);
|
|
61
|
+
if (status !== "approved") {
|
|
62
|
+
return { kind: "denied", reason: `Grant ${status}` };
|
|
63
|
+
}
|
|
64
|
+
const token = await fetchGrantToken(idp, grant.id);
|
|
65
|
+
await verifyAndConsume(token, resolved);
|
|
66
|
+
return { kind: "approved", grantId: grant.id, mode: "adapter" };
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
consola.debug(`ape-shell: adapter resolve failed, falling back to session grant:`, err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const grantsUrl = await getGrantsEndpoint(idp);
|
|
73
|
+
try {
|
|
74
|
+
const grants = await apiFetch(
|
|
75
|
+
`${grantsUrl}?requester=${encodeURIComponent(auth.email)}&status=approved&limit=20`
|
|
76
|
+
);
|
|
77
|
+
const sessionGrant = grants.data.find(
|
|
78
|
+
(g) => g.request.audience === "ape-shell" && g.request.target_host === options.targetHost && g.request.grant_type !== "once"
|
|
79
|
+
);
|
|
80
|
+
if (sessionGrant) {
|
|
81
|
+
return { kind: "approved", grantId: sessionGrant.id, mode: "session" };
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
consola.debug(`ape-shell: session grant lookup failed:`, err);
|
|
85
|
+
}
|
|
86
|
+
consola.info(`Requesting ape-shell session grant on ${options.targetHost}`);
|
|
87
|
+
try {
|
|
88
|
+
const grant = await apiFetch(grantsUrl, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
body: {
|
|
91
|
+
requester: auth.email,
|
|
92
|
+
target_host: options.targetHost,
|
|
93
|
+
audience: "ape-shell",
|
|
94
|
+
grant_type: options.approval ?? "once",
|
|
95
|
+
command: ["bash", "-c", line],
|
|
96
|
+
reason: `Shell session: ${line.slice(0, 100)}`
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
consola.info(`Approve at: ${idp}/grant-approval?grant_id=${grant.id}`);
|
|
100
|
+
const maxWait = 3e5;
|
|
101
|
+
const interval = 3e3;
|
|
102
|
+
const start = Date.now();
|
|
103
|
+
while (Date.now() - start < maxWait) {
|
|
104
|
+
const status = await apiFetch(`${grantsUrl}/${grant.id}`);
|
|
105
|
+
if (status.status === "approved")
|
|
106
|
+
return { kind: "approved", grantId: grant.id, mode: "session" };
|
|
107
|
+
if (status.status === "denied" || status.status === "revoked")
|
|
108
|
+
return { kind: "denied", reason: `Grant ${status.status}` };
|
|
109
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
110
|
+
}
|
|
111
|
+
return { kind: "denied", reason: "Grant approval timed out after 5 minutes" };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
114
|
+
return { kind: "denied", reason: `Grant request failed: ${msg}` };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/shell/pty-bridge.ts
|
|
119
|
+
import { randomBytes } from "crypto";
|
|
120
|
+
import * as pty from "node-pty";
|
|
121
|
+
var PtyBridge = class {
|
|
122
|
+
marker;
|
|
123
|
+
/** Compiled once. Captures the exit code in group 1. */
|
|
124
|
+
markerRegex;
|
|
125
|
+
term;
|
|
126
|
+
events;
|
|
127
|
+
/** Bytes received since the last completed line. Searched for the marker. */
|
|
128
|
+
pending = "";
|
|
129
|
+
/**
|
|
130
|
+
* Accumulated output for the current in-flight line. Streams to `onOutput`
|
|
131
|
+
* live as chunks arrive, and is handed to `onLineDone` in full when the
|
|
132
|
+
* marker is matched. Resets at that point.
|
|
133
|
+
*/
|
|
134
|
+
currentLineBuffer = "";
|
|
135
|
+
/** True until bash prints its first marker-prompt. */
|
|
136
|
+
readyForFirstLine = false;
|
|
137
|
+
awaitingInitialPrompt = null;
|
|
138
|
+
constructor(events, options = {}) {
|
|
139
|
+
this.events = events;
|
|
140
|
+
this.marker = randomBytes(16).toString("hex");
|
|
141
|
+
this.markerRegex = new RegExp(
|
|
142
|
+
`__APES_${this.marker}__:(-?\\d+):__END__\\r?\\n?`
|
|
143
|
+
);
|
|
144
|
+
const cols = options.cols ?? process.stdout.columns ?? 80;
|
|
145
|
+
const rows = options.rows ?? process.stdout.rows ?? 24;
|
|
146
|
+
this.term = pty.spawn("bash", ["--login", "-i"], {
|
|
147
|
+
name: "xterm-256color",
|
|
148
|
+
cols,
|
|
149
|
+
rows,
|
|
150
|
+
cwd: options.cwd ?? process.cwd(),
|
|
151
|
+
env: {
|
|
152
|
+
...process.env,
|
|
153
|
+
// Force our marker PS1 on every prompt and keep pty echo off —
|
|
154
|
+
// both survive .bashrc overrides because PROMPT_COMMAND runs
|
|
155
|
+
// before each prompt.
|
|
156
|
+
PROMPT_COMMAND: `stty -echo 2>/dev/null; PS1='__APES_${this.marker}__:$?:__END__'`,
|
|
157
|
+
// Also set it initially so the very first prompt carries the marker.
|
|
158
|
+
PS1: `__APES_${this.marker}__:$?:__END__`,
|
|
159
|
+
PS2: "> ",
|
|
160
|
+
// Silence bash-specific onboarding that would pollute our output.
|
|
161
|
+
BASH_SILENCE_DEPRECATION_WARNING: "1"
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
this.term.onData((chunk) => this.handleData(chunk));
|
|
165
|
+
this.term.onExit(({ exitCode, signal }) => {
|
|
166
|
+
this.events.onExit(exitCode, signal);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Resolves once bash has printed its very first marker-bearing prompt,
|
|
171
|
+
* which means it has finished sourcing ~/.bashrc and is ready to accept
|
|
172
|
+
* input. Callers should await this before sending the first line.
|
|
173
|
+
*/
|
|
174
|
+
waitForReady() {
|
|
175
|
+
if (this.readyForFirstLine)
|
|
176
|
+
return Promise.resolve();
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
this.awaitingInitialPrompt = resolve;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Write a shell command line to bash's stdin. The caller must ensure the
|
|
183
|
+
* line has already been approved by the grant flow. The bridge does NOT
|
|
184
|
+
* validate or filter the line.
|
|
185
|
+
*/
|
|
186
|
+
writeLine(line) {
|
|
187
|
+
const clean = line.replace(/\r?\n+$/, "");
|
|
188
|
+
this.term.write(`${clean}
|
|
189
|
+
`);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Raw passthrough write — used for forwarding user keystrokes during
|
|
193
|
+
* interactive output mode (e.g. while vim is running).
|
|
194
|
+
*/
|
|
195
|
+
writeRaw(data) {
|
|
196
|
+
this.term.write(data);
|
|
197
|
+
}
|
|
198
|
+
/** Resize the underlying pty. Called on SIGWINCH. */
|
|
199
|
+
resize(cols, rows) {
|
|
200
|
+
this.term.resize(cols, rows);
|
|
201
|
+
}
|
|
202
|
+
/** Kill the bash child. Called on Ctrl-D / shell exit. */
|
|
203
|
+
kill(signal) {
|
|
204
|
+
this.term.kill(signal);
|
|
205
|
+
}
|
|
206
|
+
/** Process id of the bash child, for logging / debugging. */
|
|
207
|
+
get pid() {
|
|
208
|
+
return this.term.pid;
|
|
209
|
+
}
|
|
210
|
+
/** Exposed for tests that want to look at the raw marker. */
|
|
211
|
+
getMarkerForTest() {
|
|
212
|
+
return this.marker;
|
|
213
|
+
}
|
|
214
|
+
// ----- internals -----
|
|
215
|
+
handleData(chunk) {
|
|
216
|
+
this.pending += chunk;
|
|
217
|
+
for (; ; ) {
|
|
218
|
+
const match = this.pending.match(this.markerRegex);
|
|
219
|
+
if (!match || match.index === void 0)
|
|
220
|
+
break;
|
|
221
|
+
const before = this.pending.slice(0, match.index);
|
|
222
|
+
const exitCode = Number(match[1]);
|
|
223
|
+
if (before.length > 0) {
|
|
224
|
+
this.currentLineBuffer += before;
|
|
225
|
+
if (this.readyForFirstLine)
|
|
226
|
+
this.events.onOutput(before);
|
|
227
|
+
}
|
|
228
|
+
this.pending = this.pending.slice(match.index + match[0].length);
|
|
229
|
+
if (!this.readyForFirstLine) {
|
|
230
|
+
this.readyForFirstLine = true;
|
|
231
|
+
this.currentLineBuffer = "";
|
|
232
|
+
const resolve = this.awaitingInitialPrompt;
|
|
233
|
+
this.awaitingInitialPrompt = null;
|
|
234
|
+
if (resolve)
|
|
235
|
+
resolve();
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const frame = { output: this.currentLineBuffer, exitCode };
|
|
239
|
+
this.currentLineBuffer = "";
|
|
240
|
+
this.events.onLineDone(frame);
|
|
241
|
+
}
|
|
242
|
+
if (!this.readyForFirstLine)
|
|
243
|
+
return;
|
|
244
|
+
const lastNewline = this.pending.lastIndexOf("\n");
|
|
245
|
+
if (lastNewline >= 0) {
|
|
246
|
+
const ready = this.pending.slice(0, lastNewline + 1);
|
|
247
|
+
this.pending = this.pending.slice(lastNewline + 1);
|
|
248
|
+
if (ready.length > 0) {
|
|
249
|
+
this.currentLineBuffer += ready;
|
|
250
|
+
this.events.onOutput(ready);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// src/shell/repl.ts
|
|
257
|
+
import { createInterface } from "readline";
|
|
258
|
+
import { homedir } from "os";
|
|
259
|
+
import { join } from "path";
|
|
260
|
+
import consola2 from "consola";
|
|
261
|
+
|
|
262
|
+
// src/shell/multi-line.ts
|
|
263
|
+
import { spawnSync } from "child_process";
|
|
264
|
+
var CONTINUE_PATTERNS = [
|
|
265
|
+
/syntax error: unexpected end of file/i,
|
|
266
|
+
/unexpected end of file/i,
|
|
267
|
+
/here-document.+delimited by end-of-file/i,
|
|
268
|
+
/unexpected EOF while looking for matching/i
|
|
269
|
+
];
|
|
270
|
+
function hasUnterminatedHeredoc(buffer) {
|
|
271
|
+
const pattern = /<<(-?)\s*(['"]?)([A-Z_]\w*)\2/gi;
|
|
272
|
+
for (const match of buffer.matchAll(pattern)) {
|
|
273
|
+
const stripTabs = match[1] === "-";
|
|
274
|
+
const delimiter = match[3];
|
|
275
|
+
const afterMatch = buffer.slice((match.index ?? 0) + match[0].length);
|
|
276
|
+
const lines = afterMatch.split("\n").slice(1);
|
|
277
|
+
const terminated = lines.some((line) => {
|
|
278
|
+
const compare = stripTabs ? line.replace(/^\t+/, "") : line;
|
|
279
|
+
return compare === delimiter;
|
|
280
|
+
});
|
|
281
|
+
if (!terminated)
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
function checkMultiLineStatus(buffer) {
|
|
287
|
+
if (buffer.trim().length === 0)
|
|
288
|
+
return { kind: "complete" };
|
|
289
|
+
if (hasUnterminatedHeredoc(buffer))
|
|
290
|
+
return { kind: "continue" };
|
|
291
|
+
const result = spawnSync("bash", ["-n", "-c", buffer], {
|
|
292
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
293
|
+
encoding: "utf-8"
|
|
294
|
+
});
|
|
295
|
+
if (result.error) {
|
|
296
|
+
return { kind: "error", message: `Failed to spawn bash for syntax check: ${result.error.message}` };
|
|
297
|
+
}
|
|
298
|
+
if (result.status === 0)
|
|
299
|
+
return { kind: "complete" };
|
|
300
|
+
const stderr = result.stderr || "";
|
|
301
|
+
for (const pattern of CONTINUE_PATTERNS) {
|
|
302
|
+
if (pattern.test(stderr))
|
|
303
|
+
return { kind: "continue" };
|
|
304
|
+
}
|
|
305
|
+
return { kind: "error", message: stderr.trim() || "Syntax error" };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/shell/repl.ts
|
|
309
|
+
var HISTORY_FILE = join(homedir(), ".config", "apes", "shell-history");
|
|
310
|
+
var PS1 = "apes$ ";
|
|
311
|
+
var PS2 = "> ";
|
|
312
|
+
var ShellRepl = class {
|
|
313
|
+
events;
|
|
314
|
+
input;
|
|
315
|
+
output;
|
|
316
|
+
quiet;
|
|
317
|
+
rl = null;
|
|
318
|
+
/** Accumulated input across multi-line continuations. */
|
|
319
|
+
buffer = "";
|
|
320
|
+
/** Whether the REPL has called `stop`. */
|
|
321
|
+
stopped = false;
|
|
322
|
+
constructor(events, options = {}) {
|
|
323
|
+
this.events = events;
|
|
324
|
+
this.input = options.input ?? process.stdin;
|
|
325
|
+
this.output = options.output ?? process.stdout;
|
|
326
|
+
this.quiet = options.quiet ?? false;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Start the REPL. Resolves when the user ends the session (Ctrl-D) or
|
|
330
|
+
* `stop()` is called from outside. Errors bubble out of `onLine` and
|
|
331
|
+
* cause the line to be rejected, but do NOT tear down the REPL.
|
|
332
|
+
*/
|
|
333
|
+
async run() {
|
|
334
|
+
if (this.stopped)
|
|
335
|
+
return;
|
|
336
|
+
this.rl = createInterface({
|
|
337
|
+
input: this.input,
|
|
338
|
+
output: this.output,
|
|
339
|
+
prompt: PS1,
|
|
340
|
+
historySize: 1e3,
|
|
341
|
+
// Enable tab completion fallback (file names + history) via default.
|
|
342
|
+
terminal: true
|
|
343
|
+
});
|
|
344
|
+
if (!this.quiet)
|
|
345
|
+
this.writeBanner();
|
|
346
|
+
this.safePrompt(PS1);
|
|
347
|
+
return new Promise((resolve) => {
|
|
348
|
+
this.rl.on("line", async (line) => {
|
|
349
|
+
try {
|
|
350
|
+
await this.handleLine(line);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
353
|
+
consola2.error(`Shell error: ${msg}`);
|
|
354
|
+
this.resetBuffer();
|
|
355
|
+
this.safePrompt(PS1);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
this.rl.on("SIGINT", () => {
|
|
359
|
+
if (this.buffer.length > 0)
|
|
360
|
+
this.output.write("\n");
|
|
361
|
+
this.resetBuffer();
|
|
362
|
+
this.safePrompt(PS1);
|
|
363
|
+
});
|
|
364
|
+
this.rl.on("close", async () => {
|
|
365
|
+
this.stopped = true;
|
|
366
|
+
await this.events.onExit();
|
|
367
|
+
resolve();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Request the REPL to stop cleanly. Equivalent to the user pressing
|
|
373
|
+
* Ctrl-D. Typically called during shutdown or after a fatal error.
|
|
374
|
+
*/
|
|
375
|
+
stop() {
|
|
376
|
+
if (this.rl)
|
|
377
|
+
this.rl.close();
|
|
378
|
+
}
|
|
379
|
+
// ----- internals -----
|
|
380
|
+
writeBanner() {
|
|
381
|
+
this.output.write("apes interactive shell\n");
|
|
382
|
+
this.output.write("Ctrl-D to exit.\n");
|
|
383
|
+
this.output.write("\n");
|
|
384
|
+
}
|
|
385
|
+
async handleLine(rawLine) {
|
|
386
|
+
this.buffer = this.buffer.length === 0 ? rawLine : `${this.buffer}
|
|
387
|
+
${rawLine}`;
|
|
388
|
+
const status = checkMultiLineStatus(this.buffer);
|
|
389
|
+
if (status.kind === "continue") {
|
|
390
|
+
this.safePrompt(PS2);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (status.kind === "error") {
|
|
394
|
+
this.output.write(`${status.message}
|
|
395
|
+
`);
|
|
396
|
+
this.resetBuffer();
|
|
397
|
+
this.safePrompt(PS1);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const completeLine = this.buffer;
|
|
401
|
+
this.resetBuffer();
|
|
402
|
+
if (completeLine.trim().length === 0) {
|
|
403
|
+
this.safePrompt(PS1);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
await this.events.onLine(completeLine);
|
|
407
|
+
this.safePrompt(PS1);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Draw a prompt, but only if the readline interface is still alive.
|
|
411
|
+
* The onLine callback may have triggered `stop()` (e.g., bash exited),
|
|
412
|
+
* in which case setPrompt/prompt would throw ERR_USE_AFTER_CLOSE.
|
|
413
|
+
*/
|
|
414
|
+
safePrompt(prompt) {
|
|
415
|
+
if (this.stopped || !this.rl)
|
|
416
|
+
return;
|
|
417
|
+
try {
|
|
418
|
+
this.rl.setPrompt(prompt);
|
|
419
|
+
this.rl.prompt();
|
|
420
|
+
} catch (err) {
|
|
421
|
+
const code = err?.code;
|
|
422
|
+
if (code !== "ERR_USE_AFTER_CLOSE")
|
|
423
|
+
throw err;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
resetBuffer() {
|
|
427
|
+
this.buffer = "";
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// src/shell/session.ts
|
|
432
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
433
|
+
var ShellSession = class {
|
|
434
|
+
id;
|
|
435
|
+
startedAt;
|
|
436
|
+
lineSeq = 0;
|
|
437
|
+
constructor(options) {
|
|
438
|
+
this.id = randomBytes2(8).toString("hex");
|
|
439
|
+
this.startedAt = Date.now();
|
|
440
|
+
appendAuditLog({
|
|
441
|
+
action: "shell-session-start",
|
|
442
|
+
session_id: this.id,
|
|
443
|
+
host: options.host,
|
|
444
|
+
requester: options.requester
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Record a granted line that was approved for execution. Called after the
|
|
449
|
+
* grant flow returns approval but before (or right after) bash runs the
|
|
450
|
+
* command. Stores the grant id so the line can be traced back to the
|
|
451
|
+
* specific grant that authorized it.
|
|
452
|
+
*/
|
|
453
|
+
logLineGranted(params) {
|
|
454
|
+
const seq = ++this.lineSeq;
|
|
455
|
+
appendAuditLog({
|
|
456
|
+
action: "shell-session-line",
|
|
457
|
+
session_id: this.id,
|
|
458
|
+
seq,
|
|
459
|
+
line: params.line,
|
|
460
|
+
grant_id: params.grantId,
|
|
461
|
+
grant_mode: params.grantMode,
|
|
462
|
+
status: "executing"
|
|
463
|
+
});
|
|
464
|
+
return seq;
|
|
465
|
+
}
|
|
466
|
+
/** Record the final exit code of a previously-granted line. */
|
|
467
|
+
logLineDone(params) {
|
|
468
|
+
appendAuditLog({
|
|
469
|
+
action: "shell-session-line-done",
|
|
470
|
+
session_id: this.id,
|
|
471
|
+
seq: params.seq,
|
|
472
|
+
exit_code: params.exitCode
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/** Record that a line was denied by the grant flow and never reached bash. */
|
|
476
|
+
logLineDenied(params) {
|
|
477
|
+
const seq = ++this.lineSeq;
|
|
478
|
+
appendAuditLog({
|
|
479
|
+
action: "shell-session-line",
|
|
480
|
+
session_id: this.id,
|
|
481
|
+
seq,
|
|
482
|
+
line: params.line,
|
|
483
|
+
status: "denied",
|
|
484
|
+
reason: params.reason
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
/** Record session termination. Fires on clean Ctrl-D or bash death. */
|
|
488
|
+
close() {
|
|
489
|
+
appendAuditLog({
|
|
490
|
+
action: "shell-session-end",
|
|
491
|
+
session_id: this.id,
|
|
492
|
+
duration_ms: Date.now() - this.startedAt,
|
|
493
|
+
lines: this.lineSeq
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// src/shell/orchestrator.ts
|
|
499
|
+
async function runInteractiveShell() {
|
|
500
|
+
let pendingResolve = null;
|
|
501
|
+
let lastExitCode = 0;
|
|
502
|
+
let shuttingDown = false;
|
|
503
|
+
let repl = null;
|
|
504
|
+
const bridge = new PtyBridge(
|
|
505
|
+
{
|
|
506
|
+
onOutput: (chunk) => {
|
|
507
|
+
process.stdout.write(chunk);
|
|
508
|
+
},
|
|
509
|
+
onLineDone: (frame) => {
|
|
510
|
+
if (pendingResolve) {
|
|
511
|
+
const r = pendingResolve;
|
|
512
|
+
pendingResolve = null;
|
|
513
|
+
lastExitCode = frame.exitCode;
|
|
514
|
+
r();
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
onExit: (exitCode) => {
|
|
518
|
+
if (!shuttingDown) {
|
|
519
|
+
process.stdout.write(`
|
|
520
|
+
[bash exited with code ${exitCode}]
|
|
521
|
+
`);
|
|
522
|
+
repl?.stop();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
);
|
|
527
|
+
await bridge.waitForReady();
|
|
528
|
+
const targetHost = hostname();
|
|
529
|
+
const auth = loadAuth();
|
|
530
|
+
const session = new ShellSession({
|
|
531
|
+
host: targetHost,
|
|
532
|
+
requester: auth?.email ?? "unknown"
|
|
533
|
+
});
|
|
534
|
+
repl = new ShellRepl(
|
|
535
|
+
{
|
|
536
|
+
onLine: async (line) => {
|
|
537
|
+
const grant = await requestGrantForShellLine(line, {
|
|
538
|
+
targetHost,
|
|
539
|
+
approval: "once"
|
|
540
|
+
});
|
|
541
|
+
if (grant.kind === "denied") {
|
|
542
|
+
session.logLineDenied({ line, reason: grant.reason });
|
|
543
|
+
consola3.error(grant.reason);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const seq = session.logLineGranted({
|
|
547
|
+
line,
|
|
548
|
+
grantId: grant.grantId,
|
|
549
|
+
grantMode: grant.mode
|
|
550
|
+
});
|
|
551
|
+
const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
|
|
552
|
+
if (process.stdin.isTTY && !wasRaw)
|
|
553
|
+
process.stdin.setRawMode(true);
|
|
554
|
+
const forward = (chunk) => {
|
|
555
|
+
bridge.writeRaw(chunk.toString());
|
|
556
|
+
};
|
|
557
|
+
const rawInputAvailable = process.stdin.isTTY === true;
|
|
558
|
+
if (rawInputAvailable)
|
|
559
|
+
process.stdin.on("data", forward);
|
|
560
|
+
try {
|
|
561
|
+
await new Promise((resolve) => {
|
|
562
|
+
pendingResolve = resolve;
|
|
563
|
+
bridge.writeLine(line);
|
|
564
|
+
});
|
|
565
|
+
} finally {
|
|
566
|
+
if (rawInputAvailable) {
|
|
567
|
+
process.stdin.off("data", forward);
|
|
568
|
+
if (!wasRaw && process.stdin.isTTY)
|
|
569
|
+
process.stdin.setRawMode(false);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
session.logLineDone({ seq, exitCode: lastExitCode });
|
|
573
|
+
if (lastExitCode !== 0) {
|
|
574
|
+
consola3.debug(`(exit ${lastExitCode})`);
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
onExit: () => {
|
|
578
|
+
shuttingDown = true;
|
|
579
|
+
session.close();
|
|
580
|
+
try {
|
|
581
|
+
bridge.kill();
|
|
582
|
+
} catch {
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
);
|
|
587
|
+
const onResize = () => {
|
|
588
|
+
const cols = process.stdout.columns ?? 80;
|
|
589
|
+
const rows = process.stdout.rows ?? 24;
|
|
590
|
+
try {
|
|
591
|
+
bridge.resize(cols, rows);
|
|
592
|
+
} catch {
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
process.stdout.on("resize", onResize);
|
|
596
|
+
const restoreTty = () => {
|
|
597
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
598
|
+
try {
|
|
599
|
+
process.stdin.setRawMode(false);
|
|
600
|
+
} catch {
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
process.on("exit", restoreTty);
|
|
605
|
+
const gracefulShutdown = () => {
|
|
606
|
+
if (shuttingDown)
|
|
607
|
+
return;
|
|
608
|
+
shuttingDown = true;
|
|
609
|
+
restoreTty();
|
|
610
|
+
try {
|
|
611
|
+
session.close();
|
|
612
|
+
} catch {
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
bridge.kill();
|
|
616
|
+
} catch {
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
repl?.stop();
|
|
620
|
+
} catch {
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
process.on("SIGTERM", gracefulShutdown);
|
|
624
|
+
process.on("SIGHUP", gracefulShutdown);
|
|
625
|
+
try {
|
|
626
|
+
await repl.run();
|
|
627
|
+
} finally {
|
|
628
|
+
process.stdout.off("resize", onResize);
|
|
629
|
+
process.off("exit", restoreTty);
|
|
630
|
+
process.off("SIGTERM", gracefulShutdown);
|
|
631
|
+
process.off("SIGHUP", gracefulShutdown);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
export {
|
|
635
|
+
runInteractiveShell
|
|
636
|
+
};
|
|
637
|
+
//# sourceMappingURL=orchestrator-JAMWD6DD.js.map
|