jevway 0.1.2 → 0.3.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 +227 -149
- package/SETUP.md +212 -0
- package/dist/browser/downloads.js +103 -0
- package/dist/browser/frames.js +21 -1
- package/dist/browser/snapshot.js +53 -0
- package/dist/browser/tab.js +59 -10
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +76 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/inbox-command.js +199 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +54 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +221 -0
- package/dist/cli/session-commands.js +132 -0
- package/dist/daemon/client.js +175 -0
- package/dist/daemon/paths.js +35 -0
- package/dist/daemon/protocol.js +28 -0
- package/dist/daemon/server.js +339 -0
- package/dist/inbox/inbox-file.js +43 -0
- package/dist/inbox/inbox-registry.js +33 -0
- package/dist/inbox/inbox.js +127 -0
- package/dist/inbox/mail-api.js +52 -0
- package/dist/inbox/message.js +34 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- package/dist/jev/secrets.js +4 -3
- package/dist/journeys/journey-doc.js +2 -1
- package/dist/journeys/journey-session.js +14 -3
- package/dist/journeys/render-viewer.js +163 -150
- package/dist/mcp/inbox-tools.js +82 -0
- package/dist/mcp/instructions.js +27 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +57 -0
- package/dist/mcp/task-tools.js +213 -0
- package/dist/mcp.js +10 -337
- package/dist/notify/desktop.js +40 -0
- package/dist/notify/person-messages.js +50 -0
- package/dist/output/layout.js +86 -0
- package/dist/output/manifest.js +86 -0
- package/dist/output/run-folder.js +80 -0
- package/dist/recording/describe-step.js +69 -0
- package/dist/{task → recording}/recording-page.js +164 -18
- package/dist/{task → recording}/recording.js +33 -91
- package/dist/recording/run-page.js +155 -0
- package/dist/session/session-service.js +213 -0
- package/dist/setup/atomic-write.js +13 -0
- package/dist/setup/clients.js +180 -0
- package/dist/setup/command-output.js +15 -0
- package/dist/setup/doctor-command.js +16 -7
- package/dist/setup/doctor.js +17 -2
- package/dist/setup/env-file.js +22 -0
- package/dist/setup/setup-command.js +104 -0
- package/dist/setup/setup-wizard.js +216 -0
- package/dist/setup/skill-command.js +124 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/ask-user.js +182 -0
- package/dist/task/downloads.js +63 -0
- package/dist/task/person-wait.js +104 -0
- package/dist/task/present.js +111 -0
- package/dist/task/run-task.js +332 -32
- package/dist/task/start-task.js +70 -11
- package/dist/task/totp.js +124 -0
- package/dist/task/upload-files.js +237 -5
- package/package.json +10 -8
- package/skill/jevway/SKILL.md +54 -15
- package/skill/jevway-inbox/SKILL.md +61 -0
- package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
- package/INSTALL-FOR-AGENTS.md +0 -138
- package/dist/cli.js +0 -112
package/dist/task/start-task.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { connectChrome } from '../browser/chrome.js';
|
|
2
|
+
import { dropStagedDownloads, useDownloads } from '../browser/downloads.js';
|
|
2
3
|
import { Tab } from '../browser/tab.js';
|
|
3
4
|
import { createAsk, profileOptions, resolveModel } from '../jev/model.js';
|
|
4
5
|
import { replayMode } from '../jev/replay.js';
|
|
@@ -6,6 +7,8 @@ import { JourneySession } from '../journeys/journey-session.js';
|
|
|
6
7
|
import { missingKeyMessage } from '../setup/env-file.js';
|
|
7
8
|
import { hostsFromEnv, narrowHosts, outsideHosts } from './hosts.js';
|
|
8
9
|
import { BrowserTask } from './run-task.js';
|
|
10
|
+
import { retryingAsk } from '../jev/retry.js';
|
|
11
|
+
import { desktopNotifier } from '../notify/desktop.js';
|
|
9
12
|
/**
|
|
10
13
|
* Connects a browser once and starts tasks on tabs inside it. Tasks and journeys need the chosen model's key:
|
|
11
14
|
* `TYPESAFE_API_KEY` for Jev (default), `OPENROUTER_API_KEY` for Clef-omni. Without it the runner is still made, so an
|
|
@@ -29,7 +32,12 @@ export const createRunner = (source, choice = {}) => {
|
|
|
29
32
|
return (client ??= createAsk(profile));
|
|
30
33
|
};
|
|
31
34
|
let chrome;
|
|
32
|
-
|
|
35
|
+
// Downloads are set up once per connection: staged in jevway's own browser, Chrome's own behaviour in the person's.
|
|
36
|
+
const browser = () => (chrome ??= connectChrome(source).then(async (handle) => {
|
|
37
|
+
await useDownloads(handle.cdp, source).catch(() => undefined);
|
|
38
|
+
return handle;
|
|
39
|
+
}));
|
|
40
|
+
let shown;
|
|
33
41
|
return {
|
|
34
42
|
/** Throws the missing-key message, so the CLI can report it as a usage error before any browser starts. */
|
|
35
43
|
requireKey() {
|
|
@@ -39,30 +47,38 @@ export const createRunner = (source, choice = {}) => {
|
|
|
39
47
|
const decider = ask();
|
|
40
48
|
// The server's lists, narrowed by the call; a start URL outside them is never opened.
|
|
41
49
|
const hosts = narrowHosts(hostsFromEnv(), { allowed: allowedHosts, blocked: blockedHosts });
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
throw new Error(`Not opened: ${outside}`);
|
|
50
|
+
if (url)
|
|
51
|
+
refuseOutside(url, hosts);
|
|
45
52
|
const { cdp } = await browser();
|
|
46
53
|
if (!url && !tab)
|
|
47
54
|
throw new Error('Pass `url` to open a new tab or `tab` to reuse an open one.');
|
|
48
55
|
const page = url ? await Tab.open(cdp, url) : await Tab.find(cdp, tab);
|
|
56
|
+
const visible = await (shown ??= browserShown(source, cdp));
|
|
49
57
|
const task = new BrowserTask(page, decider, {
|
|
50
58
|
...profileOptions(profile),
|
|
51
59
|
...definedOnly(options),
|
|
52
60
|
hosts,
|
|
53
61
|
screenshots: options.screenshots ?? screenshots,
|
|
62
|
+
waitForPersonSeconds: personWaitSeconds(options.waitForPersonSeconds),
|
|
63
|
+
person: { visible, notify: desktopNotifier() },
|
|
54
64
|
});
|
|
55
65
|
task.update({ confirmIrreversible, confirmTarget });
|
|
56
66
|
return task;
|
|
57
67
|
},
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Reads a page with no model call and no input: an open tab by URL part, or a URL in a background tab closed again.
|
|
70
|
+
* With `pdf`, the page printed as a PDF comes with it.
|
|
71
|
+
*/
|
|
72
|
+
async look({ url, tab, pdf = false }) {
|
|
61
73
|
if (!url && !tab)
|
|
62
74
|
throw new Error('Pass `url` to open a page or `tab` to read an open one.');
|
|
75
|
+
if (url)
|
|
76
|
+
refuseOutside(url, hostsFromEnv());
|
|
77
|
+
const { cdp } = await browser();
|
|
63
78
|
const page = url ? await Tab.open(cdp, url) : await Tab.find(cdp, tab);
|
|
64
79
|
try {
|
|
65
|
-
|
|
80
|
+
const state = await page.observe();
|
|
81
|
+
return pdf ? { ...state, pdf: await page.pdf() } : state;
|
|
66
82
|
}
|
|
67
83
|
finally {
|
|
68
84
|
if (url)
|
|
@@ -72,20 +88,63 @@ export const createRunner = (source, choice = {}) => {
|
|
|
72
88
|
/** Mobile journey mapping; in the user's own Chrome it opens a separate window so screenshots keep rendering. */
|
|
73
89
|
async startJourney(options) {
|
|
74
90
|
const decider = ask();
|
|
91
|
+
// The server's host lists hold for journeys too: a start page outside them is never opened, and a step that
|
|
92
|
+
// lands outside them is refused before Jev is asked about the page.
|
|
93
|
+
const hosts = hostsFromEnv();
|
|
94
|
+
refuseOutside(options.startUrl, hosts);
|
|
75
95
|
const { cdp } = await browser();
|
|
76
|
-
|
|
96
|
+
// A journey makes one model call per screen and has no loop-level retry, so a busy API gets two more tries.
|
|
97
|
+
return JourneySession.start(cdp, retryingAsk(decider, { delaysMs: JOURNEY_RETRY_MS }), {
|
|
77
98
|
newWindow: source === 'auto',
|
|
78
99
|
...profileOptions(profile),
|
|
79
100
|
...options,
|
|
101
|
+
hosts,
|
|
80
102
|
screenshots: options.screenshots ?? screenshots,
|
|
81
103
|
});
|
|
82
104
|
},
|
|
83
105
|
async close() {
|
|
84
|
-
if (chrome)
|
|
85
|
-
|
|
106
|
+
if (chrome) {
|
|
107
|
+
const handle = await chrome;
|
|
108
|
+
await handle.close();
|
|
109
|
+
dropStagedDownloads(handle.cdp);
|
|
110
|
+
}
|
|
86
111
|
chrome = undefined;
|
|
112
|
+
shown = undefined;
|
|
87
113
|
},
|
|
88
114
|
};
|
|
89
115
|
};
|
|
116
|
+
// Waits before each retry of a journey's model call after a busy or flaky API answer.
|
|
117
|
+
const JOURNEY_RETRY_MS = [1000, 3000];
|
|
118
|
+
// Waits for a person at a wall this long, in seconds, when the call names no wait and JEVWAY_PERSON_WAIT_SECONDS is unset.
|
|
119
|
+
const PERSON_WAIT_SECONDS = 120;
|
|
120
|
+
/**
|
|
121
|
+
* The call's wait for a person, else `JEVWAY_PERSON_WAIT_SECONDS` (whole seconds), else 120. A headless browser waits 0
|
|
122
|
+
* whatever this says (the task knows whether a person can see it).
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* personWaitSeconds(undefined, { JEVWAY_PERSON_WAIT_SECONDS: '60' }) // 60
|
|
126
|
+
*/
|
|
127
|
+
export const personWaitSeconds = (call, env = process.env) => {
|
|
128
|
+
const raw = env.JEVWAY_PERSON_WAIT_SECONDS?.trim();
|
|
129
|
+
return call ?? (raw && /^\d+$/.test(raw) ? Number(raw) : PERSON_WAIT_SECONDS);
|
|
130
|
+
};
|
|
131
|
+
/** Whether a person can see this browser: never `launch-headless`, nor a headless Chrome behind a port or `auto`. */
|
|
132
|
+
const browserShown = async (source, cdp) => {
|
|
133
|
+
if (source === 'launch-headless')
|
|
134
|
+
return false;
|
|
135
|
+
try {
|
|
136
|
+
const { userAgent } = await cdp.send('Browser.getVersion');
|
|
137
|
+
return !/HeadlessChrome/.test(String(userAgent));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
/** Throws before a page outside the host rules is opened. */
|
|
144
|
+
const refuseOutside = (url, hosts) => {
|
|
145
|
+
const outside = outsideHosts(url, hosts);
|
|
146
|
+
if (outside)
|
|
147
|
+
throw new Error(`Not opened: ${outside}`);
|
|
148
|
+
};
|
|
90
149
|
// Optional MCP/CLI arguments arrive as explicit undefined; they must not erase profile settings.
|
|
91
150
|
const definedOnly = (options) => Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
3
|
+
// RFC 4226 asks for at least 128 bits and recommends 160; real services hand out 80. Fewer is a mistake, such as a code passed as the secret.
|
|
4
|
+
const MIN_SECRET_BITS = 80;
|
|
5
|
+
const ALGORITHMS = { SHA1: 'sha1', SHA256: 'sha256', SHA512: 'sha512' };
|
|
6
|
+
// A code typed with less than this left in its period could expire before the submit that follows it reaches the server.
|
|
7
|
+
const FRESH_FOR_MS = 8_000;
|
|
8
|
+
const decodeBase32 = (text) => {
|
|
9
|
+
const base32 = text.replace(/[\s-]/g, '').replace(/=+$/, '').toUpperCase();
|
|
10
|
+
if (!base32)
|
|
11
|
+
throw new Error('is empty: pass the authenticator secret (base32) or its otpauth://totp/ URI');
|
|
12
|
+
if (![...base32].every((char) => BASE32_ALPHABET.includes(char))) {
|
|
13
|
+
throw new Error('is not base32 (letters A–Z and digits 2–7) or an otpauth://totp/ URI');
|
|
14
|
+
}
|
|
15
|
+
const bits = base32.length * 5;
|
|
16
|
+
if (bits < MIN_SECRET_BITS) {
|
|
17
|
+
throw new Error(`is too short for an authenticator secret (${base32.length} characters, at least 16): pass the secret behind the QR code, not a code`);
|
|
18
|
+
}
|
|
19
|
+
const bytes = [];
|
|
20
|
+
let buffer = 0;
|
|
21
|
+
let held = 0;
|
|
22
|
+
for (const char of base32) {
|
|
23
|
+
buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(char);
|
|
24
|
+
held += 5;
|
|
25
|
+
if (held >= 8) {
|
|
26
|
+
held -= 8;
|
|
27
|
+
bytes.push((buffer >>> held) & 0xff);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { key: Buffer.from(bytes), base32 };
|
|
31
|
+
};
|
|
32
|
+
const wholeNumberIn = (raw, name, fallback, min, max) => {
|
|
33
|
+
if (raw === null)
|
|
34
|
+
return fallback;
|
|
35
|
+
const value = Number(raw);
|
|
36
|
+
if (!/^\d+$/.test(raw) || value < min || value > max)
|
|
37
|
+
throw new Error(`has ${name} "${raw}" in its URI; it must be ${min} to ${max}`);
|
|
38
|
+
return value;
|
|
39
|
+
};
|
|
40
|
+
const parseOtpauth = (text) => {
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(text);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error('is not a valid otpauth://totp/ URI');
|
|
47
|
+
}
|
|
48
|
+
if (url.protocol !== 'otpauth:' || url.host.toLowerCase() !== 'totp')
|
|
49
|
+
throw new Error('must be base32 or an otpauth://totp/ URI (counter-based hotp is not supported)');
|
|
50
|
+
const secret = url.searchParams.get('secret');
|
|
51
|
+
if (!secret)
|
|
52
|
+
throw new Error('has no `secret` in its otpauth URI');
|
|
53
|
+
const algorithmName = url.searchParams.get('algorithm');
|
|
54
|
+
const algorithm = ALGORITHMS[(algorithmName ?? 'SHA1').toUpperCase()];
|
|
55
|
+
if (!algorithm)
|
|
56
|
+
throw new Error(`has algorithm "${algorithmName}" in its URI; it must be SHA1, SHA256 or SHA512`);
|
|
57
|
+
return {
|
|
58
|
+
...decodeBase32(secret),
|
|
59
|
+
digits: wholeNumberIn(url.searchParams.get('digits'), 'digits', 6, 6, 8),
|
|
60
|
+
periodSeconds: wholeNumberIn(url.searchParams.get('period'), 'period', 30, 1, 300),
|
|
61
|
+
algorithm,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Reads an authenticator secret: base32 as apps print it (any case, spaced or hyphenated, padded or not; SHA1, 6 digits,
|
|
66
|
+
* 30 s), or an `otpauth://totp/` URI whose `digits`, `period` and `algorithm` are honoured. Throws a message that never
|
|
67
|
+
* repeats the secret.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* parseTotpSecret('JBSW Y3DP EHPK 3PXP') // { digits: 6, periodSeconds: 30, algorithm: 'sha1', … }
|
|
71
|
+
*/
|
|
72
|
+
export const parseTotpSecret = (text) => {
|
|
73
|
+
const given = text.trim();
|
|
74
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(given))
|
|
75
|
+
return { given, ...parseOtpauth(given) };
|
|
76
|
+
return { given, ...decodeBase32(given), digits: 6, periodSeconds: 30, algorithm: 'sha1' };
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* The caller's `totp` secrets by key, checked before any browser work; a key may not also be one of `values`. An input
|
|
80
|
+
* error names the key (`totp.<key>: …`) and never the secret.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* checkTotpSecrets({ github_otp: 'JBSWY3DPEHPK3PXP' }).get('github_otp')?.digits // 6
|
|
84
|
+
*/
|
|
85
|
+
export const checkTotpSecrets = (totp, values = {}) => new Map(Object.entries(totp ?? {}).map(([key, text]) => {
|
|
86
|
+
try {
|
|
87
|
+
if (key in values)
|
|
88
|
+
throw new Error('is also given in values; pass a key in one of them');
|
|
89
|
+
return [key, parseTotpSecret(text)];
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
throw new Error(`totp.${key}: ${error.message}`);
|
|
93
|
+
}
|
|
94
|
+
}));
|
|
95
|
+
/** Each authenticator secret as the scrubber masks it, under its key: as given and as base32. */
|
|
96
|
+
export const totpMasks = (authenticators) => [...authenticators].flatMap(([key, secret]) => [
|
|
97
|
+
[key, secret.given],
|
|
98
|
+
[key, secret.base32],
|
|
99
|
+
]);
|
|
100
|
+
/** The RFC 6238 code for the period that holds `atMs`. */
|
|
101
|
+
export const totpCode = (secret, atMs) => {
|
|
102
|
+
const counter = Buffer.alloc(8);
|
|
103
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(atMs / 1000 / secret.periodSeconds)));
|
|
104
|
+
const mac = createHmac(secret.algorithm, secret.key).update(counter).digest();
|
|
105
|
+
const offset = mac[mac.length - 1] & 0x0f;
|
|
106
|
+
const binary = mac.readUInt32BE(offset) & 0x7fffffff;
|
|
107
|
+
return String(binary % 10 ** secret.digits).padStart(secret.digits, '0');
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* The code to type now: the current one, or, with less than 8 s (a third of a short period) left, the next period's
|
|
111
|
+
* after `pause` waits for it. After the wait the code is for the new period even if the clock reads earlier.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* await typingCode(secret, { now: Date.now, pause: (ms) => limit.pause(ms) }) // '287082'
|
|
115
|
+
*/
|
|
116
|
+
export const typingCode = async (secret, { now, pause }) => {
|
|
117
|
+
const at = now();
|
|
118
|
+
const periodMs = secret.periodSeconds * 1000;
|
|
119
|
+
const left = periodMs - (at % periodMs);
|
|
120
|
+
if (left >= Math.min(FRESH_FOR_MS, periodMs / 3))
|
|
121
|
+
return totpCode(secret, at);
|
|
122
|
+
await pause(left);
|
|
123
|
+
return totpCode(secret, Math.max(now(), at + left));
|
|
124
|
+
};
|
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
import { accessSync, constants, statSync } from 'node:fs';
|
|
1
|
+
import { accessSync, constants, createWriteStream, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
3
|
+
import { request } from 'node:https';
|
|
4
|
+
import { BlockList, isIP } from 'node:net';
|
|
2
5
|
import { basename, isAbsolute } from 'node:path';
|
|
6
|
+
import { finished } from 'node:stream/promises';
|
|
7
|
+
import { safeFileName, uniqueFileName } from '../output/layout.js';
|
|
8
|
+
import { outsideHosts } from './hosts.js';
|
|
9
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
10
|
+
const MAX_REDIRECTS = 3;
|
|
11
|
+
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
12
|
+
const FETCH_TIMEOUT_MS = 60_000;
|
|
13
|
+
/** True for a `files` value that is a URL to fetch rather than a local path. */
|
|
14
|
+
const isUrl = (value) => SCHEME.test(value) && !/^[a-z]:[\\/]/i.test(value);
|
|
3
15
|
/**
|
|
4
|
-
* Checks the files a caller declares for upload, before any browser work. Each
|
|
5
|
-
*
|
|
6
|
-
* only ever picks one of these keys. Not restricted to a working
|
|
16
|
+
* Checks the files a caller declares for upload, before any browser work. Each is an absolute path to a readable regular
|
|
17
|
+
* file, or an `https` URL fetched before the run (`fetchUploadUrls`). They are kept apart from `values`, so typed text
|
|
18
|
+
* can never become a path or a path typed text; the model only ever picks one of these keys. Not restricted to a working
|
|
19
|
+
* directory: the server's own is unrelated to the user's.
|
|
7
20
|
*
|
|
8
21
|
* @example
|
|
9
|
-
* checkUploadFiles({ avatar: '/Users/ada/me.png'
|
|
22
|
+
* checkUploadFiles({ avatar: '/Users/ada/me.png', cv: 'https://files.example/cv.pdf' }); // as given
|
|
10
23
|
* checkUploadFiles({ avatar: 'me.png' }); // throws: files.avatar: "me.png" is not an absolute path
|
|
11
24
|
*/
|
|
12
25
|
export const checkUploadFiles = (files) => {
|
|
@@ -14,6 +27,14 @@ export const checkUploadFiles = (files) => {
|
|
|
14
27
|
const refuse = (why) => {
|
|
15
28
|
throw new Error(`files.${key}: "${path}" ${why}`);
|
|
16
29
|
};
|
|
30
|
+
if (isUrl(path)) {
|
|
31
|
+
const url = URL.canParse(path) ? new URL(path) : undefined;
|
|
32
|
+
if (url?.protocol !== 'https:' || !url.hostname)
|
|
33
|
+
refuse('is not an https URL');
|
|
34
|
+
if (url.username || url.password)
|
|
35
|
+
refuse('carries a user name or password; put the file on disk instead');
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
17
38
|
if (!isAbsolute(path))
|
|
18
39
|
refuse('is not an absolute path');
|
|
19
40
|
let isFile = false;
|
|
@@ -41,3 +62,214 @@ export const checkUploadFiles = (files) => {
|
|
|
41
62
|
* fileNames({ avatar: '/Users/ada/me.png' }); // { avatar: 'me.png' }
|
|
42
63
|
*/
|
|
43
64
|
export const fileNames = (files) => Object.fromEntries(Object.entries(files).map(([key, path]) => [key, basename(path)]));
|
|
65
|
+
// Every range a fetch for the caller must never reach: this machine, the local network, link-local (cloud metadata),
|
|
66
|
+
// carrier-grade NAT, multicast and reserved space, and the IPv6 forms that lead back into them.
|
|
67
|
+
const NOT_PUBLIC = new BlockList();
|
|
68
|
+
for (const [network, prefix] of [
|
|
69
|
+
['0.0.0.0', 8],
|
|
70
|
+
['10.0.0.0', 8],
|
|
71
|
+
['100.64.0.0', 10],
|
|
72
|
+
['127.0.0.0', 8],
|
|
73
|
+
['169.254.0.0', 16],
|
|
74
|
+
['172.16.0.0', 12],
|
|
75
|
+
['192.0.0.0', 24],
|
|
76
|
+
['192.0.2.0', 24],
|
|
77
|
+
['192.168.0.0', 16],
|
|
78
|
+
['198.18.0.0', 15],
|
|
79
|
+
['198.51.100.0', 24],
|
|
80
|
+
['203.0.113.0', 24],
|
|
81
|
+
['224.0.0.0', 3],
|
|
82
|
+
]) {
|
|
83
|
+
NOT_PUBLIC.addSubnet(network, prefix, 'ipv4');
|
|
84
|
+
}
|
|
85
|
+
for (const [network, prefix] of [
|
|
86
|
+
['::', 128],
|
|
87
|
+
['::1', 128],
|
|
88
|
+
['64:ff9b::', 96],
|
|
89
|
+
['64:ff9b:1::', 48],
|
|
90
|
+
['100::', 64],
|
|
91
|
+
['2001:db8::', 32],
|
|
92
|
+
['fc00::', 7],
|
|
93
|
+
['fe80::', 10],
|
|
94
|
+
['ff00::', 8],
|
|
95
|
+
]) {
|
|
96
|
+
NOT_PUBLIC.addSubnet(network, prefix, 'ipv6');
|
|
97
|
+
}
|
|
98
|
+
// An IPv4-mapped IPv6 address (`::ffff:127.0.0.1`, `::ffff:7f00:1`) as the IPv4 address it reaches; undefined otherwise.
|
|
99
|
+
const mappedIpv4 = (address) => {
|
|
100
|
+
const match = /^::ffff:(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/i.exec(address);
|
|
101
|
+
if (!match)
|
|
102
|
+
return undefined;
|
|
103
|
+
if (match[1])
|
|
104
|
+
return match[1];
|
|
105
|
+
const high = parseInt(match[2], 16);
|
|
106
|
+
const low = parseInt(match[3], 16);
|
|
107
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join('.');
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Whether an address is on the public internet: never this machine, a private or link-local network, CGNAT, multicast,
|
|
111
|
+
* reserved space, or an IPv6 form (mapped, NAT64) of one.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* publicAddress('93.184.215.14') // true
|
|
115
|
+
* publicAddress('::ffff:127.0.0.1') // false
|
|
116
|
+
*/
|
|
117
|
+
export const publicAddress = (address) => {
|
|
118
|
+
const mapped = mappedIpv4(address);
|
|
119
|
+
if (mapped)
|
|
120
|
+
return publicAddress(mapped);
|
|
121
|
+
const family = isIP(address);
|
|
122
|
+
if (!family)
|
|
123
|
+
return false;
|
|
124
|
+
return !NOT_PUBLIC.check(address, family === 4 ? 'ipv4' : 'ipv6');
|
|
125
|
+
};
|
|
126
|
+
// A URL whose host is an address: the connection skips `lookup`, so it is checked here, by itself.
|
|
127
|
+
const privateLiteral = (url) => {
|
|
128
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
129
|
+
return isIP(host) !== 0 && !publicAddress(host);
|
|
130
|
+
};
|
|
131
|
+
const resolveAll = (host) => dnsLookup(host, { all: true });
|
|
132
|
+
/**
|
|
133
|
+
* A `lookup` for the HTTPS connection that resolves the host, refuses it when any address is not public, and hands the
|
|
134
|
+
* checked address to the connection, so the name can't resolve elsewhere between the check and the connect.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* request(url, { lookup: vettedLookup() }) // a host resolving to 10.0.0.5 fails: "… resolves to 10.0.0.5, a private or local address"
|
|
138
|
+
*/
|
|
139
|
+
export const vettedLookup = (resolve = resolveAll) => (hostname, options, callback) => {
|
|
140
|
+
resolve(hostname).then((addresses) => {
|
|
141
|
+
const blocked = addresses.find(({ address }) => !publicAddress(address));
|
|
142
|
+
if (blocked || !addresses.length) {
|
|
143
|
+
callback(new Error(`${hostname} resolves to ${blocked?.address ?? 'nothing'}, a private or local address`), '', 4);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (options.all)
|
|
147
|
+
callback(null, addresses);
|
|
148
|
+
else
|
|
149
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
150
|
+
}, (error) => callback(error, '', 4));
|
|
151
|
+
};
|
|
152
|
+
const httpsGet = (url, signal) => new Promise((resolve, reject) => {
|
|
153
|
+
const outgoing = request(url, { method: 'GET', lookup: vettedLookup(), timeout: FETCH_TIMEOUT_MS, signal, headers: { 'user-agent': 'jevway' } }, (incoming) => resolve({ status: incoming.statusCode ?? 0, headers: incoming.headers, body: incoming, close: () => incoming.destroy() }));
|
|
154
|
+
outgoing.on('timeout', () => outgoing.destroy(new Error(`no answer within ${FETCH_TIMEOUT_MS / 1000} s`)));
|
|
155
|
+
outgoing.on('error', reject);
|
|
156
|
+
outgoing.end();
|
|
157
|
+
});
|
|
158
|
+
const header = (headers, name) => {
|
|
159
|
+
const value = headers[name];
|
|
160
|
+
return Array.isArray(value) ? value[0] : value;
|
|
161
|
+
};
|
|
162
|
+
// The file name a server gives: RFC 5987 `filename*` first, then plain `filename`.
|
|
163
|
+
const dispositionName = (disposition) => {
|
|
164
|
+
if (!disposition)
|
|
165
|
+
return undefined;
|
|
166
|
+
const extended = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(disposition);
|
|
167
|
+
if (extended) {
|
|
168
|
+
try {
|
|
169
|
+
return decodeURIComponent(extended[2].trim());
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// fall through to the plain name
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return /filename\s*=\s*(?:"([^"]*)"|([^;]+))/i.exec(disposition)?.slice(1).find(Boolean)?.trim();
|
|
176
|
+
};
|
|
177
|
+
const urlName = (url) => {
|
|
178
|
+
try {
|
|
179
|
+
return decodeURIComponent(url.pathname.split('/').pop() ?? '');
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return url.pathname.split('/').pop() ?? '';
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Fetches every `https` value of `files` into `dir` and returns the files with each URL swapped for its path; local paths
|
|
187
|
+
* pass through. Only `https`, at most 3 redirects, each hop inside `hosts` and connected only to public addresses, at
|
|
188
|
+
* most 50 MB. The name comes from Content-Disposition, else the URL path, made safe, else the key. `known` remembers
|
|
189
|
+
* what a session already fetched. Every error starts with the key and the URL.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* await fetchUploadUrls({ cv: 'https://files.example/cv.pdf' }, { dir: '<run>/uploads' }) // { cv: '<run>/uploads/cv.pdf' }
|
|
193
|
+
*/
|
|
194
|
+
export const fetchUploadUrls = async (files, { dir, hosts = { blocked: [] }, get = httpsGet, known = new Map(), maxBytes = MAX_UPLOAD_BYTES, signal, }) => {
|
|
195
|
+
const fetched = {};
|
|
196
|
+
for (const [key, value] of Object.entries(files)) {
|
|
197
|
+
if (!isUrl(value)) {
|
|
198
|
+
fetched[key] = value;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const path = known.get(value) ?? (await fetchOne({ key, value, dir, hosts, get, maxBytes, signal }));
|
|
202
|
+
known.set(value, path);
|
|
203
|
+
fetched[key] = path;
|
|
204
|
+
}
|
|
205
|
+
return fetched;
|
|
206
|
+
};
|
|
207
|
+
const fetchOne = async ({ key, value, dir, hosts, get, maxBytes, signal, }) => {
|
|
208
|
+
const refuse = (why) => {
|
|
209
|
+
throw new Error(`files.${key}: "${value}" ${why}`);
|
|
210
|
+
};
|
|
211
|
+
let url = new URL(value);
|
|
212
|
+
const outside = outsideHosts(url.href, hosts);
|
|
213
|
+
if (outside)
|
|
214
|
+
refuse(`is outside this run's hosts: ${outside}`);
|
|
215
|
+
if (privateLiteral(url))
|
|
216
|
+
refuse('points at a private or local address');
|
|
217
|
+
for (let redirects = 0;; redirects++) {
|
|
218
|
+
const response = await get(url, signal).catch((error) => refuse(`could not be fetched: ${error.message}`));
|
|
219
|
+
const location = header(response.headers, 'location');
|
|
220
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
221
|
+
response.close();
|
|
222
|
+
if (redirects >= MAX_REDIRECTS)
|
|
223
|
+
refuse(`redirected more than ${MAX_REDIRECTS} times`);
|
|
224
|
+
const next = new URL(location, url);
|
|
225
|
+
if (next.protocol !== 'https:')
|
|
226
|
+
refuse(`redirected to ${next.href}, and only https URLs are fetched`);
|
|
227
|
+
if (outsideHosts(next.href, hosts))
|
|
228
|
+
refuse(`redirected to ${next.href}, which is outside this run's hosts`);
|
|
229
|
+
if (privateLiteral(next))
|
|
230
|
+
refuse(`redirected to ${next.href}, a private or local address`);
|
|
231
|
+
if (next.username || next.password)
|
|
232
|
+
refuse('redirected to a URL with a user name or password');
|
|
233
|
+
url = next;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (response.status < 200 || response.status >= 300) {
|
|
237
|
+
response.close();
|
|
238
|
+
refuse(`answered ${response.status}`);
|
|
239
|
+
}
|
|
240
|
+
if (Number(header(response.headers, 'content-length') ?? 0) > maxBytes) {
|
|
241
|
+
response.close();
|
|
242
|
+
refuse(`is larger than the ${capWords(maxBytes)} cap`);
|
|
243
|
+
}
|
|
244
|
+
mkdirSync(dir, { recursive: true });
|
|
245
|
+
const path = uniqueFileName(dir, safeFileName(dispositionName(header(response.headers, 'content-disposition')) ?? urlName(url), key));
|
|
246
|
+
await save(response, path, maxBytes).catch((error) => {
|
|
247
|
+
rmSync(path, { force: true });
|
|
248
|
+
refuse(error.message === TOO_BIG ? `is larger than the ${capWords(maxBytes)} cap` : `could not be fetched: ${error.message}`);
|
|
249
|
+
});
|
|
250
|
+
return path;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const TOO_BIG = 'too big';
|
|
254
|
+
const capWords = (bytes) => (bytes % (1024 * 1024) === 0 ? `${bytes / (1024 * 1024)} MB` : `${bytes}-byte`);
|
|
255
|
+
// Streams the body to `path`, counting bytes: a body that passes the cap is cut off there.
|
|
256
|
+
const save = async (response, path, maxBytes) => {
|
|
257
|
+
const out = createWriteStream(path);
|
|
258
|
+
let bytes = 0;
|
|
259
|
+
try {
|
|
260
|
+
for await (const chunk of response.body) {
|
|
261
|
+
bytes += chunk.length;
|
|
262
|
+
if (bytes > maxBytes)
|
|
263
|
+
throw new Error(TOO_BIG);
|
|
264
|
+
if (!out.write(chunk))
|
|
265
|
+
await new Promise((resolve) => out.once('drain', () => resolve()));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
response.close();
|
|
270
|
+
out.destroy();
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
out.end();
|
|
274
|
+
await finished(out);
|
|
275
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jevway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Hand a browser goal to TypeSafe Jev; get a short verified summary back. MCP server + CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"url": "git+https://github.com/AbdallahAHO/jevway.git"
|
|
26
26
|
},
|
|
27
27
|
"bin": {
|
|
28
|
-
"jevway": "dist/cli.js",
|
|
28
|
+
"jevway": "dist/cli/main.js",
|
|
29
29
|
"jevway-mcp": "dist/mcp.js"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
33
|
"skill",
|
|
34
|
-
"
|
|
34
|
+
"SETUP.md",
|
|
35
35
|
"LICENSE",
|
|
36
36
|
"LICENSE.jev-ultrafast"
|
|
37
37
|
],
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"node": ">=24"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"build": "tsc -p tsconfig.build.json && cp src/browser/snapshot.js dist/browser/snapshot.js && chmod +x dist/cli.js dist/mcp.js",
|
|
42
|
+
"build": "tsc -p tsconfig.build.json && cp src/browser/snapshot.js dist/browser/snapshot.js && chmod +x dist/cli/main.js dist/mcp.js",
|
|
43
43
|
"typecheck": "tsc --noEmit",
|
|
44
44
|
"test": "vitest run",
|
|
45
45
|
"prepublishOnly": "pnpm typecheck && pnpm test && rm -rf dist && pnpm build",
|
|
@@ -47,8 +47,9 @@
|
|
|
47
47
|
"release:status": "node release/release.ts status",
|
|
48
48
|
"release:check": "node release/release.ts check",
|
|
49
49
|
"verify": "node --env-file-if-exists=.env verify/run.ts",
|
|
50
|
+
"verify:mcp": "node verify/mcp-consumer.ts",
|
|
50
51
|
"mcp": "node --env-file-if-exists=.env src/mcp.ts",
|
|
51
|
-
"cli": "node --env-file-if-exists=.env src/cli.ts",
|
|
52
|
+
"cli": "node --env-file-if-exists=.env src/cli/main.ts",
|
|
52
53
|
"verify:reader": "node verify/reader.ts",
|
|
53
54
|
"verify:journeys": "node --env-file-if-exists=.env verify/journeys.ts",
|
|
54
55
|
"verify:real": "node --env-file-if-exists=.env verify/real-world.ts",
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
"verify:forgejo": "node --env-file-if-exists=.env verify/forgejo.ts",
|
|
62
63
|
"verify:signup": "node --env-file-if-exists=.env verify/signup.ts",
|
|
63
64
|
"verify:vikunja": "node --env-file-if-exists=.env verify/vikunja.ts",
|
|
64
|
-
"verify:agent": "node verify/agent.ts",
|
|
65
|
+
"verify:agent": "node verify/agent/agent.ts",
|
|
65
66
|
"verify:replay": "node verify/replay.ts",
|
|
66
67
|
"report:render": "node verify/jobs/rerender.ts",
|
|
67
68
|
"apps:forgejo:up": "docker compose -p jevway-forgejo -f verify/apps/forgejo/compose.yml up -d --wait && node verify/apps/forgejo/seed.ts users",
|
|
@@ -70,12 +71,13 @@
|
|
|
70
71
|
"apps:vikunja:down": "docker compose -p jevway-vikunja -f verify/apps/vikunja/compose.yml down -v"
|
|
71
72
|
},
|
|
72
73
|
"dependencies": {
|
|
73
|
-
"@modelcontextprotocol/
|
|
74
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
74
75
|
"@typesafe-ai/sdk": "^0.7.0",
|
|
75
76
|
"zod": "^4.6.5"
|
|
76
77
|
},
|
|
77
78
|
"devDependencies": {
|
|
78
|
-
"@
|
|
79
|
+
"@modelcontextprotocol/client": "^2.4.0",
|
|
80
|
+
"@types/node": "^24.19.2",
|
|
79
81
|
"typescript": "^7.0.2",
|
|
80
82
|
"vitest": "^5.0.1"
|
|
81
83
|
}
|