@farmslot/adapter-web 0.4.1 → 0.6.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/CHANGELOG.md +10 -0
- package/README.md +24 -1
- package/dist/launch-browser.d.cts +12 -0
- package/dist/web-dapp/cleanup.d.mts +15 -0
- package/dist/web-dapp/index.d.mts +123 -0
- package/dist/web-dapp/inject.d.mts +12 -0
- package/dist/web-dapp/launch.d.mts +56 -0
- package/dist/web-dapp/lib/blocked-hosts.d.mts +3 -0
- package/dist/web-dapp/lib/cdp-client.d.mts +8 -0
- package/dist/web-dapp/lib/display.d.mts +26 -0
- package/dist/web-dapp/lib/hud.d.mts +14 -0
- package/dist/web-dapp/lib/paths.d.mts +11 -0
- package/dist/web-dapp/lib/policy-fence.d.mts +18 -0
- package/dist/web-dapp/lib/policy.d.mts +4 -0
- package/dist/web-dapp/lib/processes.d.mts +57 -0
- package/dist/web-dapp/lib/readiness.d.mts +85 -0
- package/dist/web-dapp/lib/runtime.d.mts +29 -0
- package/dist/web-dapp/lib/signers.d.mts +13 -0
- package/dist/web-dapp/lib/viem.d.mts +1 -0
- package/dist/web-dapp/stop.d.mts +2 -0
- package/dist/web-dapp/verify.d.mts +2 -0
- package/dist/web-dapp/wallet-host.d.mts +2 -0
- package/package.json +17 -10
- package/src/launch-browser.cjs +20 -4
- package/src/web-dapp/cleanup.mjs +74 -0
- package/src/web-dapp/index.mjs +590 -0
- package/src/web-dapp/inject.mjs +100 -0
- package/src/web-dapp/launch.mjs +806 -0
- package/src/web-dapp/lib/blocked-hosts.mjs +24 -0
- package/src/web-dapp/lib/cdp-client.mjs +86 -0
- package/src/web-dapp/lib/display.mjs +169 -0
- package/src/web-dapp/lib/hud.mjs +156 -0
- package/src/web-dapp/lib/paths.mjs +55 -0
- package/src/web-dapp/lib/policy-fence.mjs +128 -0
- package/src/web-dapp/lib/policy.mjs +108 -0
- package/src/web-dapp/lib/processes.mjs +215 -0
- package/src/web-dapp/lib/readiness.mjs +438 -0
- package/src/web-dapp/lib/runtime.mjs +195 -0
- package/src/web-dapp/lib/signers.mjs +105 -0
- package/src/web-dapp/lib/viem.mjs +26 -0
- package/src/web-dapp/stop.mjs +44 -0
- package/src/web-dapp/verify.mjs +68 -0
- package/src/web-dapp/wallet-host.mjs +728 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The venue policy of the adapter that extends web-dapp (e.g. the Terminal
|
|
2
|
+
// plugin's plugins/terminal/policy.mjs): what web-dapp's launch, wallet host,
|
|
3
|
+
// readiness and actions know about the app's venue. mm-harness sets its module
|
|
4
|
+
// path in RECIPE_WEB_DAPP_POLICY when it binds such an adapter, so every
|
|
5
|
+
// web-dapp leaf process and live action it starts reads the same policy.
|
|
6
|
+
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { assertPolicyDigest, POLICY_DIGEST_ENV } from './policy-fence.mjs';
|
|
11
|
+
|
|
12
|
+
export const POLICY_ENV = 'RECIPE_WEB_DAPP_POLICY';
|
|
13
|
+
// The policy contract this web-dapp reads; a policy declares the version it was written for.
|
|
14
|
+
export const POLICY_VERSION = 1;
|
|
15
|
+
let loaded;
|
|
16
|
+
|
|
17
|
+
// Every member web-dapp reads, checked once when the policy loads: a missing or mistyped
|
|
18
|
+
// member fails here, naming the module, instead of deep in a launch or an assertion (a
|
|
19
|
+
// missing signatureLog would silently drop the venue's L1 and mainnet rules).
|
|
20
|
+
const MEMBERS = {
|
|
21
|
+
adapterId: 'string',
|
|
22
|
+
checkout: 'object',
|
|
23
|
+
venueHosts: 'function',
|
|
24
|
+
linkHosts: 'array',
|
|
25
|
+
probe: 'object',
|
|
26
|
+
startChain: 'number',
|
|
27
|
+
refuseTypedData: 'object',
|
|
28
|
+
signatureLog: 'object',
|
|
29
|
+
startPath: 'string',
|
|
30
|
+
pagePath: 'function',
|
|
31
|
+
dependencies: 'array',
|
|
32
|
+
testnetVariable: 'string',
|
|
33
|
+
module: 'string',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function checkPolicy(policy, file) {
|
|
37
|
+
const problems = [];
|
|
38
|
+
if (!policy || typeof policy !== 'object') problems.push('exports no policy object');
|
|
39
|
+
else {
|
|
40
|
+
if (policy.policyVersion !== POLICY_VERSION)
|
|
41
|
+
problems.push(
|
|
42
|
+
`policyVersion is ${policy.policyVersion ?? 'missing'}, this web-dapp reads ${POLICY_VERSION}`,
|
|
43
|
+
);
|
|
44
|
+
for (const [name, type] of Object.entries(MEMBERS)) {
|
|
45
|
+
const value = policy[name];
|
|
46
|
+
const ok =
|
|
47
|
+
type === 'array'
|
|
48
|
+
? Array.isArray(value)
|
|
49
|
+
: type === 'object'
|
|
50
|
+
? value !== null && typeof value === 'object'
|
|
51
|
+
: typeof value === type;
|
|
52
|
+
if (!ok)
|
|
53
|
+
problems.push(
|
|
54
|
+
`${name} must be ${type === 'array' || type === 'object' ? `an ${type}` : `a ${type}`}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const { checkout, refuseTypedData, signatureLog } = policy;
|
|
58
|
+
if (checkout && typeof checkout.matches !== 'function')
|
|
59
|
+
problems.push('checkout.matches must be a function');
|
|
60
|
+
for (const name of ['name', 'label', 'needs'])
|
|
61
|
+
if (checkout && typeof checkout[name] !== 'string')
|
|
62
|
+
problems.push(`checkout.${name} must be a string`);
|
|
63
|
+
if (refuseTypedData && typeof refuseTypedData.reason !== 'function')
|
|
64
|
+
problems.push('refuseTypedData.reason must be a function');
|
|
65
|
+
for (const name of ['kind', 'message'])
|
|
66
|
+
if (refuseTypedData && typeof refuseTypedData[name] !== 'string')
|
|
67
|
+
problems.push(`refuseTypedData.${name} must be a string`);
|
|
68
|
+
// The venue's request-log rules: both maps are required (empty only on purpose), so a
|
|
69
|
+
// policy cannot drop its L1 or mainnet rules by omission.
|
|
70
|
+
for (const name of ['typedDataClasses', 'forbiddenEntries']) {
|
|
71
|
+
if (signatureLog && (signatureLog[name] === null || typeof signatureLog[name] !== 'object'))
|
|
72
|
+
problems.push(`signatureLog.${name} must be an object`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (problems.length) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`web-dapp venue policy ${file}: ${problems.join('; ')}.\nNext: update the library that declares the adapter to a revision written for this host.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return policy;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function webDappPolicy(env = process.env) {
|
|
84
|
+
const file = env[POLICY_ENV];
|
|
85
|
+
if (!file) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'web-dapp needs a venue policy: select an adapter that extends it.\n' +
|
|
88
|
+
'Next: put the library that declares it on RECIPE_LIBRARY_PATH and pass its id, e.g. --adapter terminal.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const digest = env[POLICY_DIGEST_ENV];
|
|
92
|
+
if (loaded?.file !== file || loaded.digest !== digest) {
|
|
93
|
+
// The digest mm-harness took when it bound the policy: refuse files that changed since.
|
|
94
|
+
if (digest) assertPolicyDigest(file, digest);
|
|
95
|
+
// The policy module uses node built-ins only and has no top-level await, so it loads synchronously.
|
|
96
|
+
loaded = {
|
|
97
|
+
file,
|
|
98
|
+
digest,
|
|
99
|
+
policy: checkPolicy(createRequire(import.meta.url)(path.resolve(file)).policy, file),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return loaded.policy;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The bound adapter's id, for names and paths; web-dapp's own when none is bound.
|
|
106
|
+
export function webDappAdapterId(env = process.env) {
|
|
107
|
+
return env[POLICY_ENV] ? webDappPolicy(env).adapterId : 'web-dapp';
|
|
108
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Stop the web-dapp slot browser and wallet host by their pid files and prove
|
|
2
|
+
// the slot CDP port is released. A pid is signalled only when its command line
|
|
3
|
+
// carries this slot's owner marker for that process kind. A pid file naming a
|
|
4
|
+
// dead process is removed; one naming a live process without the marker is
|
|
5
|
+
// kept and reported, never signalled.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { webDappPolicy, webDappRuntimeDir } from './runtime.mjs';
|
|
14
|
+
|
|
15
|
+
export function pidAlive(pid) {
|
|
16
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
17
|
+
try {
|
|
18
|
+
process.kill(Number(pid), 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return error.code === 'EPERM';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function groupAlive(pgid) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-pgid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return error.code === 'EPERM';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readPid(file) {
|
|
35
|
+
if (!existsSync(file)) return null;
|
|
36
|
+
const pid = Number(readFileSync(file, 'utf8').trim());
|
|
37
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function psField(pid, field) {
|
|
41
|
+
try {
|
|
42
|
+
return execFileSync('ps', ['-ww', '-o', `${field}=`, '-p', String(pid)], {
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
45
|
+
}).trim();
|
|
46
|
+
} catch {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function processCommand(pid) {
|
|
52
|
+
return pidAlive(pid) ? psField(pid, 'command') : '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Each process the slot starts carries `--mm-harness-owner=<kind>-<id>`, where
|
|
56
|
+
// <id> is derived from the canonical runtime dir. Ownership is that exact
|
|
57
|
+
// argument, compared as a whole whitespace-free token, so a runtime dir that
|
|
58
|
+
// shares a prefix with this one (".../terminal backup", ".../terminal2")
|
|
59
|
+
// never matches.
|
|
60
|
+
export const OWNER_FLAG = '--mm-harness-owner';
|
|
61
|
+
export const OWNER_KINDS = Object.freeze(['browser', 'wallet-host', 'console-collector']);
|
|
62
|
+
|
|
63
|
+
export function ownerMarker(kind, runtimeDir) {
|
|
64
|
+
if (!OWNER_KINDS.includes(kind)) throw new Error(`unknown web-dapp process kind ${kind}`);
|
|
65
|
+
const id = createHash('sha256').update(path.resolve(runtimeDir)).digest('hex').slice(0, 32);
|
|
66
|
+
return `${OWNER_FLAG}=${kind}-${id}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function ownsProcess(kind, pid, runtimeDir, command = processCommand(pid)) {
|
|
70
|
+
if (!command) return false;
|
|
71
|
+
return command.split(/\s+/u).includes(ownerMarker(kind, runtimeDir));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function portFree(port) {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const socket = net.connect({ host: '127.0.0.1', port }, () => {
|
|
77
|
+
socket.destroy();
|
|
78
|
+
resolve(false);
|
|
79
|
+
});
|
|
80
|
+
socket.on('error', () => resolve(true));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Browsers and hosts are spawned detached, each leading its own process group.
|
|
85
|
+
// When the pid leads its group the whole group is signalled and waited for,
|
|
86
|
+
// so children that outlive the leader (renderers, a child ignoring SIGTERM)
|
|
87
|
+
// are killed too; otherwise only the pid.
|
|
88
|
+
export async function terminate(pid, graceMs = 8000) {
|
|
89
|
+
if (!pidAlive(pid)) return false;
|
|
90
|
+
const group = Number(psField(pid, 'pgid')) === pid;
|
|
91
|
+
const alive = () => (group ? groupAlive(pid) : pidAlive(pid));
|
|
92
|
+
const signal = (name) => {
|
|
93
|
+
try {
|
|
94
|
+
process.kill(group ? -pid : pid, name);
|
|
95
|
+
} catch {
|
|
96
|
+
// Already gone.
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
signal('SIGTERM');
|
|
100
|
+
const deadline = Date.now() + graceMs;
|
|
101
|
+
while (alive() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
102
|
+
if (alive()) {
|
|
103
|
+
signal('SIGKILL');
|
|
104
|
+
const killDeadline = Date.now() + 5000;
|
|
105
|
+
while (alive() && Date.now() < killDeadline)
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The console collector a run started (the host's console capture)
|
|
112
|
+
// outlives browser relaunches inside the run; stop and cleanup end it.
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} target
|
|
115
|
+
* @param {{ log?: (message: string) => void, runtimeDir?: string }} [options]
|
|
116
|
+
*/
|
|
117
|
+
export async function stopConsoleCollector(target, { log = () => {}, runtimeDir } = {}) {
|
|
118
|
+
const runtime = runtimeDir ?? webDappRuntimeDir(target);
|
|
119
|
+
const pidFile = path.join(runtime, 'console-tail.pid');
|
|
120
|
+
const pid = readPid(pidFile);
|
|
121
|
+
if (!pid) return { stopped: null };
|
|
122
|
+
if (ownsProcess('console-collector', pid, runtime)) {
|
|
123
|
+
await terminate(pid, 3000);
|
|
124
|
+
rmSync(pidFile, { force: true });
|
|
125
|
+
log(`stopped console collector pid ${pid}`);
|
|
126
|
+
return { stopped: pid };
|
|
127
|
+
}
|
|
128
|
+
if (pidAlive(pid)) {
|
|
129
|
+
log(`console collector pid ${pid} is alive but is not this slot's collector; left it alone`);
|
|
130
|
+
return { stopped: null, foreign: pid };
|
|
131
|
+
}
|
|
132
|
+
rmSync(pidFile, { force: true });
|
|
133
|
+
return { stopped: null, stale: pid };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Every live process that proves it is a browser on this slot's profiles,
|
|
137
|
+
// found by command line: a browser started through LaunchServices has no
|
|
138
|
+
// child handle, and its pid is only known once it owns the CDP port.
|
|
139
|
+
export function ownedBrowserPids(runtimeDir) {
|
|
140
|
+
const runtime = path.resolve(runtimeDir);
|
|
141
|
+
let lines = '';
|
|
142
|
+
try {
|
|
143
|
+
// `--` ends pgrep's options: the pattern itself starts with dashes.
|
|
144
|
+
lines = execFileSync('pgrep', ['-f', '--', ownerMarker('browser', runtime)], {
|
|
145
|
+
encoding: 'utf8',
|
|
146
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
return lines
|
|
152
|
+
.split('\n')
|
|
153
|
+
.map(Number)
|
|
154
|
+
.filter((pid) => pid > 0 && ownsProcess('browser', pid, runtime));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function cdpListenerPids(port) {
|
|
158
|
+
try {
|
|
159
|
+
return execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], {
|
|
160
|
+
encoding: 'utf8',
|
|
161
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
162
|
+
})
|
|
163
|
+
.split('\n')
|
|
164
|
+
.map(Number)
|
|
165
|
+
.filter((pid) => pid > 0);
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} target
|
|
173
|
+
* @param {{ cdpPort?: number, log?: (message: string) => void, runtimeDir?: string }} [options]
|
|
174
|
+
*/
|
|
175
|
+
export async function stopWebDappBrowser(target, { cdpPort, log = () => {}, runtimeDir } = {}) {
|
|
176
|
+
const runtime = runtimeDir ?? webDappRuntimeDir(target);
|
|
177
|
+
const stopped = [];
|
|
178
|
+
const stale = [];
|
|
179
|
+
const foreign = [];
|
|
180
|
+
for (const name of ['wallet-host', 'browser']) {
|
|
181
|
+
const pidFile = path.join(runtime, `${name}.pid`);
|
|
182
|
+
const pid = readPid(pidFile);
|
|
183
|
+
if (pid && ownsProcess(name, pid, runtime)) {
|
|
184
|
+
if (await terminate(pid)) {
|
|
185
|
+
stopped.push({ name, pid });
|
|
186
|
+
log(`stopped ${name} pid ${pid}`);
|
|
187
|
+
}
|
|
188
|
+
rmSync(pidFile, { force: true });
|
|
189
|
+
} else if (pid && pidAlive(pid)) {
|
|
190
|
+
foreign.push({ name, pid });
|
|
191
|
+
log(
|
|
192
|
+
`${name} pid ${pid} is alive but is not this slot's ${name}; left it and its pid file alone`,
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
if (pid) stale.push({ name, pid });
|
|
196
|
+
rmSync(pidFile, { force: true });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
rmSync(path.join(runtime, 'wallet-host.ready'), { force: true });
|
|
200
|
+
rmSync(path.join(runtime, 'browser.json'), { force: true });
|
|
201
|
+
let free = null;
|
|
202
|
+
if (cdpPort) {
|
|
203
|
+
const deadline = Date.now() + 10000;
|
|
204
|
+
free = await portFree(cdpPort);
|
|
205
|
+
while (!free && Date.now() < deadline) {
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
207
|
+
free = await portFree(cdpPort);
|
|
208
|
+
}
|
|
209
|
+
if (!free)
|
|
210
|
+
throw new Error(
|
|
211
|
+
`CDP port ${cdpPort} is still in use after stopping the ${webDappPolicy().adapterId} browser; another process owns it.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return { stopped, stale, foreign, cdpPortFree: free };
|
|
215
|
+
}
|