@envseal/prompters 0.1.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 +201 -0
- package/dist/ide.d.ts +8 -0
- package/dist/ide.js +173 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +13 -0
- package/dist/loopback.d.ts +25 -0
- package/dist/loopback.js +415 -0
- package/dist/native.d.ts +9 -0
- package/dist/native.js +196 -0
- package/dist/none.d.ts +12 -0
- package/dist/none.js +23 -0
- package/dist/registry.d.ts +16 -0
- package/dist/registry.js +60 -0
- package/dist/tty.d.ts +10 -0
- package/dist/tty.js +166 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.js +18 -0
- package/package.json +33 -0
package/dist/loopback.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { spawn, execFile } from 'node:child_process';
|
|
4
|
+
import { asSecret } from '@envseal/protocol';
|
|
5
|
+
const BIND_HOST = '127.0.0.1';
|
|
6
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
7
|
+
const VALUE_FIELD = (key) => `env_value.${key}`;
|
|
8
|
+
const SKIP_FIELD = (key) => `env_skip.${key}`;
|
|
9
|
+
function escapeHtml(value) {
|
|
10
|
+
return value
|
|
11
|
+
.replaceAll('&', '&')
|
|
12
|
+
.replaceAll('<', '<')
|
|
13
|
+
.replaceAll('>', '>')
|
|
14
|
+
.replaceAll('"', '"')
|
|
15
|
+
.replaceAll("'", ''');
|
|
16
|
+
}
|
|
17
|
+
function secureEqual(a, b) {
|
|
18
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
19
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
20
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
|
21
|
+
}
|
|
22
|
+
function isHttpUrl(value) {
|
|
23
|
+
return value.startsWith('https://') || value.startsWith('http://');
|
|
24
|
+
}
|
|
25
|
+
function securityHeaders(styleNonce, scriptNonce) {
|
|
26
|
+
return {
|
|
27
|
+
'Cache-Control': 'no-store',
|
|
28
|
+
'Referrer-Policy': 'no-referrer',
|
|
29
|
+
'X-Frame-Options': 'DENY',
|
|
30
|
+
'X-Content-Type-Options': 'nosniff',
|
|
31
|
+
'Content-Security-Policy': `default-src 'none'; style-src 'nonce-${styleNonce}'; ` +
|
|
32
|
+
`script-src 'nonce-${scriptNonce}'; form-action 'self'; base-uri 'none'`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function respondText(res, status, body, headers) {
|
|
36
|
+
res.writeHead(status, {
|
|
37
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
38
|
+
'Content-Length': Buffer.byteLength(body),
|
|
39
|
+
Connection: 'close',
|
|
40
|
+
...headers,
|
|
41
|
+
});
|
|
42
|
+
res.end(body);
|
|
43
|
+
}
|
|
44
|
+
function renderKeySection(key) {
|
|
45
|
+
const sectionId = `field-${key.key}`;
|
|
46
|
+
const revealId = `reveal-${key.key}`;
|
|
47
|
+
let signup = '';
|
|
48
|
+
if (isHttpUrl(key.signupUrl ?? '')) {
|
|
49
|
+
signup =
|
|
50
|
+
` <p class="signup"><a href="${escapeHtml(key.signupUrl ?? '')}" ` +
|
|
51
|
+
`rel="noreferrer noopener" target="_blank">Get your key</a></p>\n`;
|
|
52
|
+
}
|
|
53
|
+
let docs = '';
|
|
54
|
+
if (isHttpUrl(key.docsUrl ?? '')) {
|
|
55
|
+
docs =
|
|
56
|
+
` <p class="docs"><a href="${escapeHtml(key.docsUrl ?? '')}" ` +
|
|
57
|
+
`rel="noreferrer noopener" target="_blank">Documentation</a></p>\n`;
|
|
58
|
+
}
|
|
59
|
+
let hint = '';
|
|
60
|
+
if (key.formatHint) {
|
|
61
|
+
hint = ` <p class="hint">${escapeHtml(key.formatHint)}</p>\n`;
|
|
62
|
+
}
|
|
63
|
+
let provider = '';
|
|
64
|
+
if (key.providerName) {
|
|
65
|
+
provider = ` <p class="provider">Provider: ${escapeHtml(key.providerName)}</p>\n`;
|
|
66
|
+
}
|
|
67
|
+
let skip = '';
|
|
68
|
+
if (key.optional) {
|
|
69
|
+
skip =
|
|
70
|
+
` <label class="skip"><input type="checkbox" ` +
|
|
71
|
+
`name="${escapeHtml(SKIP_FIELD(key.key))}" value="1"> Skip this key</label>\n`;
|
|
72
|
+
}
|
|
73
|
+
// W3-01: these two attributes are the only place a key name reaches the page
|
|
74
|
+
// without escapeHtml. Unreachable through the product path today (the
|
|
75
|
+
// manifest's zod key pattern is /^[A-Z][A-Z0-9_]{0,63}$/) and contained by
|
|
76
|
+
// the nonce-only CSP, but this is the one file that renders
|
|
77
|
+
// attacker-influenced strings next to a live credential field, so the
|
|
78
|
+
// escaping contract holds without exception.
|
|
79
|
+
return ` <section class="key" id="${escapeHtml(sectionId)}">
|
|
80
|
+
<h2>${escapeHtml(key.key)}</h2>
|
|
81
|
+
<p class="description">${escapeHtml(key.description)}</p>
|
|
82
|
+
${provider}${hint}${signup}${docs} <div class="input-row">
|
|
83
|
+
<input type="password" name="${escapeHtml(VALUE_FIELD(key.key))}" id="${escapeHtml(revealId)}"
|
|
84
|
+
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
|
85
|
+
data-1p-ignore data-lpignore="true" placeholder="Enter value">
|
|
86
|
+
<button type="button" data-reveal="${escapeHtml(revealId)}">Show</button>
|
|
87
|
+
</div>
|
|
88
|
+
${skip} </section>
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
function renderForm(req, opts) {
|
|
92
|
+
const fields = req.keys.map((k) => renderKeySection(k)).join('');
|
|
93
|
+
return `<!DOCTYPE html>
|
|
94
|
+
<html lang="en">
|
|
95
|
+
<head>
|
|
96
|
+
<meta charset="utf-8">
|
|
97
|
+
<meta name="referrer" content="no-referrer">
|
|
98
|
+
<title>EnvSeal credential prompt</title>
|
|
99
|
+
<style nonce="${opts.styleNonce}">
|
|
100
|
+
:root { color-scheme: light dark; }
|
|
101
|
+
body { font-family: system-ui, sans-serif; max-width: 44rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.45; }
|
|
102
|
+
.nonce { font-size: 1.4rem; font-weight: 700; padding: 0.75rem 1rem; border: 1px solid currentColor; border-radius: 6px; }
|
|
103
|
+
.meta { color: #777; }
|
|
104
|
+
.reason { font-size: 1.1rem; white-space: pre-wrap; background: rgba(127,127,127,.12); padding: 0.75rem 1rem; border-radius: 6px; }
|
|
105
|
+
.key { border-top: 1px solid rgba(127,127,127,.4); padding: 1rem 0; }
|
|
106
|
+
.key h2 { margin: 0 0 .25rem; font-size: 1.05rem; }
|
|
107
|
+
.description { margin: 0 0 .25rem; white-space: pre-wrap; }
|
|
108
|
+
.hint, .signup, .docs, .provider { margin: 0 0 .25rem; font-size: .9rem; color: #888; }
|
|
109
|
+
.input-row { display: flex; gap: .5rem; align-items: center; margin: .5rem 0; }
|
|
110
|
+
input[type="password"] { flex: 1; padding: .45rem .6rem; font-size: 1rem; }
|
|
111
|
+
button { padding: .45rem .9rem; font-size: 1rem; cursor: pointer; }
|
|
112
|
+
.skip { font-size: .9rem; }
|
|
113
|
+
input[type="submit"] { padding: .6rem 1.4rem; font-size: 1.05rem; }
|
|
114
|
+
</style>
|
|
115
|
+
</head>
|
|
116
|
+
<body>
|
|
117
|
+
<main>
|
|
118
|
+
<h1>EnvSeal</h1>
|
|
119
|
+
<p class="nonce">Nonce: ${escapeHtml(req.nonce)}<br>
|
|
120
|
+
<span class="meta">Verify this matches the nonce your agent displayed.</span></p>
|
|
121
|
+
<p class="meta">Project: ${escapeHtml(req.projectRoot)}</p>
|
|
122
|
+
<p class="reason">${escapeHtml(req.reason)}</p>
|
|
123
|
+
<form method="post" action="/t/${opts.pathNonce}">
|
|
124
|
+
<input type="hidden" name="csrf" value="${escapeHtml(opts.csrf)}">
|
|
125
|
+
${fields} <p><input type="submit" value="Submit"></p>
|
|
126
|
+
</form>
|
|
127
|
+
</main>
|
|
128
|
+
<script nonce="${opts.scriptNonce}">
|
|
129
|
+
for (const btn of document.querySelectorAll('[data-reveal]')) {
|
|
130
|
+
btn.addEventListener('click', function () {
|
|
131
|
+
const input = document.getElementById(btn.getAttribute('data-reveal'));
|
|
132
|
+
if (input === null) return;
|
|
133
|
+
const showing = input.type === 'password';
|
|
134
|
+
input.type = showing ? 'text' : 'password';
|
|
135
|
+
btn.textContent = showing ? 'Hide' : 'Show';
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const form = document.querySelector('form');
|
|
139
|
+
if (form !== null) {
|
|
140
|
+
form.addEventListener('submit', function () {
|
|
141
|
+
for (const btn of form.querySelectorAll('button')) btn.setAttribute('disabled', 'disabled');
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
function readBody(req, maxBytes) {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let total = 0;
|
|
153
|
+
let rejected = false;
|
|
154
|
+
req.on('data', (chunk) => {
|
|
155
|
+
total += chunk.length;
|
|
156
|
+
if (total > maxBytes) {
|
|
157
|
+
rejected = true;
|
|
158
|
+
resolve(null);
|
|
159
|
+
req.destroy();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
chunks.push(chunk);
|
|
163
|
+
});
|
|
164
|
+
req.on('end', () => {
|
|
165
|
+
if (!rejected) {
|
|
166
|
+
resolve(Buffer.concat(chunks));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
req.on('error', () => {
|
|
170
|
+
if (!rejected) {
|
|
171
|
+
resolve(null);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async function openBrowser(port, pathNonce) {
|
|
177
|
+
const url = `http://127.0.0.1:${port}/t/${pathNonce}`;
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let settled = false;
|
|
180
|
+
const finish = (ok) => {
|
|
181
|
+
if (!settled) {
|
|
182
|
+
settled = true;
|
|
183
|
+
resolve({ ok, url });
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
setTimeout(() => finish(false), 5000).unref();
|
|
187
|
+
if (process.platform === 'win32') {
|
|
188
|
+
const child = spawn('cmd', ['/c', 'start', '', url], { windowsHide: true, stdio: 'ignore' });
|
|
189
|
+
child.once('error', () => finish(false));
|
|
190
|
+
child.once('spawn', () => finish(true));
|
|
191
|
+
child.once('exit', () => finish(true));
|
|
192
|
+
}
|
|
193
|
+
else if (process.platform === 'darwin') {
|
|
194
|
+
execFile('open', [url], (err) => finish(err === null));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
execFile('xdg-open', [url], (err) => finish(err === null));
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
export class LoopbackPrompter {
|
|
202
|
+
id = 'loopback-browser';
|
|
203
|
+
options;
|
|
204
|
+
openerProbe = null;
|
|
205
|
+
current = null;
|
|
206
|
+
constructor(options) {
|
|
207
|
+
this.options = options ?? {};
|
|
208
|
+
}
|
|
209
|
+
available() {
|
|
210
|
+
if (this.openerProbe === null) {
|
|
211
|
+
this.openerProbe = probeOpener();
|
|
212
|
+
}
|
|
213
|
+
return this.openerProbe;
|
|
214
|
+
}
|
|
215
|
+
async cancel(ticket) {
|
|
216
|
+
if (this.current !== null && this.current.ticket === ticket) {
|
|
217
|
+
this.current.cancel();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async prompt(req) {
|
|
221
|
+
return this.promptWithUrl(req);
|
|
222
|
+
}
|
|
223
|
+
async promptWithUrl(req) {
|
|
224
|
+
const pathNonce = randomBytes(16).toString('hex');
|
|
225
|
+
const styleNonce = randomBytes(16).toString('base64');
|
|
226
|
+
const scriptNonce = randomBytes(16).toString('base64');
|
|
227
|
+
const csrf = randomBytes(16).toString('hex');
|
|
228
|
+
let settled = false;
|
|
229
|
+
let resolvePrompt = () => { };
|
|
230
|
+
const promptPromise = new Promise((resolve) => {
|
|
231
|
+
resolvePrompt = resolve;
|
|
232
|
+
});
|
|
233
|
+
let launchedUrl;
|
|
234
|
+
let complete = () => { };
|
|
235
|
+
let teardown = () => { };
|
|
236
|
+
const sockets = new Set();
|
|
237
|
+
// Calculate expectedHost before creating server
|
|
238
|
+
let expectedHost = '';
|
|
239
|
+
const server = createServer((incoming, outgoing) => {
|
|
240
|
+
void serveRequest(incoming, outgoing, req, pathNonce, csrf, styleNonce, scriptNonce, complete, expectedHost);
|
|
241
|
+
});
|
|
242
|
+
server.on('connection', (socket) => {
|
|
243
|
+
sockets.add(socket);
|
|
244
|
+
socket.once('close', () => sockets.delete(socket));
|
|
245
|
+
});
|
|
246
|
+
await new Promise((resolve, reject) => {
|
|
247
|
+
server.once('error', reject);
|
|
248
|
+
server.listen(0, BIND_HOST, () => resolve());
|
|
249
|
+
});
|
|
250
|
+
const address = server.address();
|
|
251
|
+
if (address === null || typeof address === 'string') {
|
|
252
|
+
server.close();
|
|
253
|
+
throw new Error('loopback server has no numeric address');
|
|
254
|
+
}
|
|
255
|
+
expectedHost = `${BIND_HOST}:${address.port}`;
|
|
256
|
+
const fallbackUrl = `http://${expectedHost}/t/${pathNonce}`;
|
|
257
|
+
this.options.onListening?.({ port: address.port, pathNonce, url: fallbackUrl });
|
|
258
|
+
const finishPrompt = (results) => {
|
|
259
|
+
if (settled) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
settled = true;
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
teardown();
|
|
265
|
+
if (launchedUrl !== undefined) {
|
|
266
|
+
resolvePrompt({ ticket: req.ticket, results, url: launchedUrl });
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
resolvePrompt({ ticket: req.ticket, results });
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
complete = finishPrompt;
|
|
273
|
+
teardown = () => {
|
|
274
|
+
if (server.listening) {
|
|
275
|
+
try {
|
|
276
|
+
server.close();
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// already closed
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const socket of sockets) {
|
|
283
|
+
socket.destroy();
|
|
284
|
+
}
|
|
285
|
+
sockets.clear();
|
|
286
|
+
};
|
|
287
|
+
const timeoutMs = Number.isFinite(req.timeoutMs) && req.timeoutMs > 0 ? Math.floor(req.timeoutMs) : 120000;
|
|
288
|
+
const timer = setTimeout(() => {
|
|
289
|
+
finishPrompt(req.keys.map((k) => ({ key: k.key, outcome: 'timeout' })));
|
|
290
|
+
}, timeoutMs);
|
|
291
|
+
timer.unref();
|
|
292
|
+
this.current = {
|
|
293
|
+
ticket: req.ticket,
|
|
294
|
+
cancel: () => {
|
|
295
|
+
this.current = null;
|
|
296
|
+
finishPrompt(req.keys.map((k) => ({ key: k.key, outcome: 'cancelled' })));
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
const launched = this.options.openBrowser === false
|
|
300
|
+
? { ok: false, url: fallbackUrl }
|
|
301
|
+
: await openBrowser(address.port, pathNonce);
|
|
302
|
+
launchedUrl = launched.ok ? undefined : launched.url;
|
|
303
|
+
const result = await promptPromise;
|
|
304
|
+
if (this.current !== null && this.current.ticket === req.ticket) {
|
|
305
|
+
this.current = null;
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function serveRequest(incoming, outgoing, req, pathNonce, csrf, styleNonce, scriptNonce, complete, expectedHost) {
|
|
311
|
+
const secHeaders = securityHeaders(styleNonce, scriptNonce);
|
|
312
|
+
if (incoming.headers.host !== expectedHost) {
|
|
313
|
+
respondText(outgoing, 400, 'Bad Request', secHeaders);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Origin must not CONTRADICT us; requiring its absence broke the real browser.
|
|
317
|
+
//
|
|
318
|
+
// Two facts, both learned by driving Chrome at this server rather than a test
|
|
319
|
+
// client:
|
|
320
|
+
// 1. Browsers send `Origin` on every POST, including same-origin ones.
|
|
321
|
+
// 2. Because this page sets `Referrer-Policy: no-referrer`, the browser
|
|
322
|
+
// sends `Origin: null` rather than the page's actual origin.
|
|
323
|
+
// The original rule ("any Origin header -> 400") therefore rejected the one
|
|
324
|
+
// submission this entire surface exists to accept. Every unit test passed
|
|
325
|
+
// because the HTTP client used in tests sends no Origin at all.
|
|
326
|
+
//
|
|
327
|
+
// Accepting only {absent, exact match, null} still refuses a genuine
|
|
328
|
+
// cross-origin post, which would carry that other origin verbatim and cannot
|
|
329
|
+
// forge this header. `null` is not a meaningful weakening: an attacker who
|
|
330
|
+
// could induce it would still need the 128-bit path nonce and the ticket-bound
|
|
331
|
+
// CSRF token, neither of which is readable cross-origin. Origin is
|
|
332
|
+
// defence-in-depth here, not the primary control.
|
|
333
|
+
const origin = incoming.headers.origin;
|
|
334
|
+
const originOk = origin === undefined || origin === `http://${expectedHost}` || origin === 'null';
|
|
335
|
+
if (!originOk) {
|
|
336
|
+
respondText(outgoing, 400, 'Bad Request', secHeaders);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const requestUrl = new URL(incoming.url ?? '', `http://${expectedHost}`);
|
|
340
|
+
const match = /^\/t\/([^/]*)$/.exec(requestUrl.pathname);
|
|
341
|
+
if (match === null) {
|
|
342
|
+
respondText(outgoing, 404, 'Not Found', secHeaders);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const candidate = match[1];
|
|
346
|
+
if (candidate === undefined || !secureEqual(candidate, pathNonce)) {
|
|
347
|
+
respondText(outgoing, 404, 'Not Found', secHeaders);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (incoming.method === 'GET') {
|
|
351
|
+
const rendered = renderForm(req, { pathNonce, csrf, styleNonce, scriptNonce });
|
|
352
|
+
outgoing.writeHead(200, {
|
|
353
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
354
|
+
'Content-Length': Buffer.byteLength(rendered),
|
|
355
|
+
Connection: 'close',
|
|
356
|
+
...secHeaders,
|
|
357
|
+
});
|
|
358
|
+
outgoing.end(rendered);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (incoming.method !== 'POST') {
|
|
362
|
+
respondText(outgoing, 405, 'Method Not Allowed', secHeaders);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const body = await readBody(incoming, MAX_BODY_BYTES);
|
|
366
|
+
if (body === null) {
|
|
367
|
+
return; // socket destroyed above the cap
|
|
368
|
+
}
|
|
369
|
+
const bodyText = body.toString('utf8');
|
|
370
|
+
body.fill(0);
|
|
371
|
+
const params = new URLSearchParams(bodyText);
|
|
372
|
+
const submittedCsrf = params.get('csrf');
|
|
373
|
+
if (submittedCsrf === null || !secureEqual(submittedCsrf, csrf)) {
|
|
374
|
+
respondText(outgoing, 403, 'Forbidden', secHeaders);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const results = [];
|
|
378
|
+
for (const key of req.keys) {
|
|
379
|
+
const skipped = params.get(SKIP_FIELD(key.key));
|
|
380
|
+
if (skipped === '1') {
|
|
381
|
+
results.push({ key: key.key, outcome: 'skipped' });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const raw = params.get(VALUE_FIELD(key.key));
|
|
385
|
+
if (raw !== null && raw.length > 0) {
|
|
386
|
+
results.push({ key: key.key, outcome: 'entered', value: asSecret(Buffer.from(raw, 'utf8')) });
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
results.push({ key: key.key, outcome: 'skipped' });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
respondText(outgoing, 200, 'You can close this tab.', secHeaders);
|
|
393
|
+
complete(results);
|
|
394
|
+
}
|
|
395
|
+
let openerProbeResult = null;
|
|
396
|
+
function probeOpener() {
|
|
397
|
+
if (openerProbeResult !== null) {
|
|
398
|
+
return openerProbeResult;
|
|
399
|
+
}
|
|
400
|
+
openerProbeResult = new Promise((resolve) => {
|
|
401
|
+
if (process.platform === 'win32') {
|
|
402
|
+
const child = spawn('cmd', ['/c', 'exit', '0'], { stdio: 'ignore' });
|
|
403
|
+
child.once('error', () => resolve(false));
|
|
404
|
+
child.once('exit', () => resolve(true));
|
|
405
|
+
}
|
|
406
|
+
else if (process.platform === 'darwin') {
|
|
407
|
+
execFile('open', ['--version'], (err) => resolve(err === null));
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
execFile('xdg-open', ['--version'], (err) => resolve(err === null));
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
return openerProbeResult;
|
|
414
|
+
}
|
|
415
|
+
//# sourceMappingURL=loopback.js.map
|
package/dist/native.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Prompter, PromptRequest, PromptResponse } from './types.js';
|
|
2
|
+
export declare class NativePrompter implements Prompter {
|
|
3
|
+
readonly id: 'native-dialog';
|
|
4
|
+
private adapter;
|
|
5
|
+
available(): Promise<boolean>;
|
|
6
|
+
cancel(_ticket: string): Promise<void>;
|
|
7
|
+
prompt(req: PromptRequest): Promise<PromptResponse>;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=native.d.ts.map
|
package/dist/native.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { openSync, writeSync, closeSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { asSecret } from '@envseal/protocol';
|
|
7
|
+
function commandExists(command) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
10
|
+
const child = spawn(probe, [command], { stdio: 'ignore' });
|
|
11
|
+
child.once('error', () => resolve(false));
|
|
12
|
+
child.once('exit', (code) => resolve(code === 0));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function runCapture(file, args, input) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const child = spawn(file, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
18
|
+
const out = [];
|
|
19
|
+
child.stdout.on('data', (chunk) => out.push(chunk));
|
|
20
|
+
child.stdin.on('error', () => { });
|
|
21
|
+
if (input !== undefined) {
|
|
22
|
+
child.stdin.write(input);
|
|
23
|
+
}
|
|
24
|
+
child.stdin.end();
|
|
25
|
+
child.once('error', () => resolve({ code: -1, stdout: '' }));
|
|
26
|
+
child.once('exit', (code) => resolve({ code, stdout: Buffer.concat(out).toString('utf8') }));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function formatLabel(key) {
|
|
30
|
+
let lines = key.key;
|
|
31
|
+
if (key.description) {
|
|
32
|
+
lines += `\n\n${key.description}`;
|
|
33
|
+
}
|
|
34
|
+
if (key.formatHint) {
|
|
35
|
+
lines += `\n\nHint: ${key.formatHint}`;
|
|
36
|
+
}
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
function escapeAppleScript(value) {
|
|
40
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
41
|
+
}
|
|
42
|
+
const macAdapter = {
|
|
43
|
+
name: 'osascript',
|
|
44
|
+
async available() {
|
|
45
|
+
return commandExists('osascript');
|
|
46
|
+
},
|
|
47
|
+
async promptOne(key, nonce) {
|
|
48
|
+
const label = escapeAppleScript(`${formatLabel(key)}\n\nNonce: ${nonce}`);
|
|
49
|
+
// Passed on stdin (`osascript -`), never argv, so the prompt does not land in `ps`.
|
|
50
|
+
const script = `set answer to text returned of (display dialog "${label}" ` +
|
|
51
|
+
`default answer "" with hidden answer)\nreturn answer\n`;
|
|
52
|
+
const { code, stdout } = await runCapture('osascript', ['-'], script);
|
|
53
|
+
if (code !== 0 || stdout === '') {
|
|
54
|
+
return { outcome: 'cancelled' };
|
|
55
|
+
}
|
|
56
|
+
const value = stdout.replace(/\r?\n$/, '');
|
|
57
|
+
return { outcome: 'entered', value };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const winAdapter = {
|
|
61
|
+
name: 'powershell',
|
|
62
|
+
async available() {
|
|
63
|
+
return commandExists('powershell');
|
|
64
|
+
},
|
|
65
|
+
async promptOne(key, nonce) {
|
|
66
|
+
// The typed value crosses stdout as hex of its UTF-8 bytes, not as text:
|
|
67
|
+
// PowerShell 5.1's [Console]::Out encodes with the OEM codepage, which
|
|
68
|
+
// mojibakes anything non-ASCII before node's utf8 decode ever sees it.
|
|
69
|
+
// Hex is pure ASCII on both sides of that pipe.
|
|
70
|
+
const psScript = `$ErrorActionPreference = 'Stop'\n` +
|
|
71
|
+
`$prompt = @'\n${formatLabel(key)}\n\nNonce: ${nonce}\n'@\n` +
|
|
72
|
+
`$secure = Read-Host -Prompt $prompt -AsSecureString\n` +
|
|
73
|
+
`if ($null -eq $secure) { exit 1 }\n` +
|
|
74
|
+
`$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)\n` +
|
|
75
|
+
`try {\n` +
|
|
76
|
+
` $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)\n` +
|
|
77
|
+
` $hex = ([Text.Encoding]::UTF8.GetBytes($plain) | ForEach-Object { $_.ToString('x2') }) -join ''\n` +
|
|
78
|
+
` [Console]::Out.Write($hex)\n` +
|
|
79
|
+
`}\n` +
|
|
80
|
+
`finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }\n`;
|
|
81
|
+
// The prompt and whatever the user types live in a 0600 temp file, never argv.
|
|
82
|
+
const tmpPath = join(tmpdir(), `envseal-${randomBytes(8).toString('hex')}.ps1`);
|
|
83
|
+
const fd = openSync(tmpPath, 'w', 0o600);
|
|
84
|
+
try {
|
|
85
|
+
writeSync(fd, psScript, null, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const { code, stdout } = await runCapture('powershell', ['-NoProfile', '-NonInteractive:$false', '-ExecutionPolicy', 'Bypass', '-File', tmpPath]);
|
|
92
|
+
const hex = stdout.replace(/\r?\n$/, '');
|
|
93
|
+
if (code !== 0 || hex === '' || !/^[0-9a-f]+$/.test(hex)) {
|
|
94
|
+
return { outcome: 'cancelled' };
|
|
95
|
+
}
|
|
96
|
+
return { outcome: 'entered', value: Buffer.from(hex, 'hex').toString('utf8') };
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
unlinkSync(tmpPath);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
class LinuxAdapter {
|
|
104
|
+
name = 'zenity/kdialog/ssh-askpass';
|
|
105
|
+
_tool = null;
|
|
106
|
+
async available() {
|
|
107
|
+
if (this._tool !== null) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
for (const candidate of ['zenity', 'kdialog', 'ssh-askpass']) {
|
|
111
|
+
if (await commandExists(candidate)) {
|
|
112
|
+
this._tool = candidate;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
async promptOne(key, nonce) {
|
|
119
|
+
if (this._tool === null) {
|
|
120
|
+
await this.available();
|
|
121
|
+
}
|
|
122
|
+
const label = `${formatLabel(key)}\n\nNonce: ${nonce}`;
|
|
123
|
+
switch (this._tool) {
|
|
124
|
+
case 'zenity': {
|
|
125
|
+
const { code, stdout } = await runCapture('zenity', ['--password', '--text', label]);
|
|
126
|
+
if (code !== 0 || stdout === '') {
|
|
127
|
+
return { outcome: 'cancelled' };
|
|
128
|
+
}
|
|
129
|
+
return { outcome: 'entered', value: stdout.replace(/\r?\n$/, '') };
|
|
130
|
+
}
|
|
131
|
+
case 'kdialog': {
|
|
132
|
+
const { code, stdout } = await runCapture('kdialog', ['--password', label]);
|
|
133
|
+
if (code !== 0 || stdout === '') {
|
|
134
|
+
return { outcome: 'cancelled' };
|
|
135
|
+
}
|
|
136
|
+
return { outcome: 'entered', value: stdout.replace(/\r?\n$/, '') };
|
|
137
|
+
}
|
|
138
|
+
case 'ssh-askpass': {
|
|
139
|
+
// Prompt goes to stdin, keeping it out of argv.
|
|
140
|
+
const { code, stdout } = await runCapture('ssh-askpass', [], label);
|
|
141
|
+
if (code !== 0 || stdout === '') {
|
|
142
|
+
return { outcome: 'cancelled' };
|
|
143
|
+
}
|
|
144
|
+
const value = stdout
|
|
145
|
+
.replace(/\r?\n$/, '')
|
|
146
|
+
.replace(new RegExp(`^${regexEscape(label)}\n?`), '');
|
|
147
|
+
return { outcome: 'entered', value };
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return { outcome: 'cancelled' };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const linuxAdapter = new LinuxAdapter();
|
|
155
|
+
function regexEscape(value) {
|
|
156
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
157
|
+
}
|
|
158
|
+
export class NativePrompter {
|
|
159
|
+
id = 'native-dialog';
|
|
160
|
+
adapter() {
|
|
161
|
+
if (process.platform === 'darwin') {
|
|
162
|
+
return macAdapter;
|
|
163
|
+
}
|
|
164
|
+
if (process.platform === 'win32') {
|
|
165
|
+
return winAdapter;
|
|
166
|
+
}
|
|
167
|
+
return linuxAdapter;
|
|
168
|
+
}
|
|
169
|
+
async available() {
|
|
170
|
+
return this.adapter().available();
|
|
171
|
+
}
|
|
172
|
+
async cancel(_ticket) {
|
|
173
|
+
// Native dialogs are short-lived; there is nothing to cancel once spawned.
|
|
174
|
+
}
|
|
175
|
+
async prompt(req) {
|
|
176
|
+
const adapter = this.adapter();
|
|
177
|
+
const results = [];
|
|
178
|
+
for (const key of req.keys) {
|
|
179
|
+
const answer = await adapter.promptOne(key, req.nonce);
|
|
180
|
+
if (answer.outcome === 'cancelled') {
|
|
181
|
+
results.push({ key: key.key, outcome: 'cancelled' });
|
|
182
|
+
for (const rest of req.keys.slice(results.length)) {
|
|
183
|
+
results.push({ key: rest.key, outcome: 'cancelled' });
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (answer.outcome === 'skipped' || answer.value === undefined || answer.value === '') {
|
|
188
|
+
results.push({ key: key.key, outcome: 'skipped' });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
results.push({ key: key.key, outcome: 'entered', value: asSecret(Buffer.from(answer.value, 'utf8')) });
|
|
192
|
+
}
|
|
193
|
+
return { ticket: req.ticket, results };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=native.js.map
|
package/dist/none.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Prompter, PromptRequest, PromptResponse } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* CI surface: fails fast and machine-readably. MUST NOT hang, MUST NOT prompt,
|
|
4
|
+
* MUST NOT fall back to asking in chat.
|
|
5
|
+
*/
|
|
6
|
+
export declare class NonePrompter implements Prompter {
|
|
7
|
+
readonly id: 'none';
|
|
8
|
+
available(): Promise<boolean>;
|
|
9
|
+
cancel(_ticket: string): Promise<void>;
|
|
10
|
+
prompt(req: PromptRequest): Promise<PromptResponse>;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=none.d.ts.map
|
package/dist/none.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SepError } from '@envseal/protocol';
|
|
2
|
+
/**
|
|
3
|
+
* CI surface: fails fast and machine-readably. MUST NOT hang, MUST NOT prompt,
|
|
4
|
+
* MUST NOT fall back to asking in chat.
|
|
5
|
+
*/
|
|
6
|
+
export class NonePrompter {
|
|
7
|
+
id = 'none';
|
|
8
|
+
async available() {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
async cancel(_ticket) {
|
|
12
|
+
// Nothing is ever shown; there is nothing to cancel.
|
|
13
|
+
}
|
|
14
|
+
async prompt(req) {
|
|
15
|
+
throw new SepError({
|
|
16
|
+
code: 'SEP_NO_INTERACTIVE_SURFACE',
|
|
17
|
+
userMessage: `No interactive surface available for keys: ${req.keys
|
|
18
|
+
.map((k) => k.key)
|
|
19
|
+
.join(', ')}. Configure them out-of-band for CI.`,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=none.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Prompter } from './types.js';
|
|
2
|
+
export interface SelectOptions {
|
|
3
|
+
/** Force a specific surface. Throws if it is not available. */
|
|
4
|
+
prefer?: Prompter['id'];
|
|
5
|
+
/** Allow the raw-TTY adapter, which collides with a harness TUI. */
|
|
6
|
+
allowTty?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function allPrompters(): Prompter[];
|
|
9
|
+
/**
|
|
10
|
+
* Select the first available surface in the order mandated by PLAN.md §5.3:
|
|
11
|
+
* prefer -> ide -> native-dialog (SEP_PREFER_NATIVE) -> loopback-browser ->
|
|
12
|
+
* tty (opt-in) -> none. A `CI` environment forces `none` unless `prefer`
|
|
13
|
+
* names a concrete surface.
|
|
14
|
+
*/
|
|
15
|
+
export declare function selectPrompter(opts?: SelectOptions): Promise<Prompter>;
|
|
16
|
+
//# sourceMappingURL=registry.d.ts.map
|