humanish 0.136.1 → 0.138.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 +23 -0
- package/dist/actors/computer-use/actor.d.ts +2 -0
- package/dist/actors/computer-use/actor.js +1 -0
- package/dist/actors/computer-use/loop/observation.js +2 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -2
- package/dist/actors/computer-use/loop/session.js +8 -12
- package/dist/actors/computer-use/loop/types.d.ts +14 -1
- package/dist/cli/commands/runs.js +2 -2
- package/dist/cli/doctor.js +24 -0
- package/dist/evidence/browser-network.d.ts +108 -0
- package/dist/evidence/browser-network.js +516 -0
- package/dist/evidence/trace-page.d.ts +31 -1
- package/dist/evidence/trace-page.js +68 -2
- package/dist/keys/key-resolution.js +53 -2
- package/dist/observer-app.html +2 -2
- package/dist/routes/computer-use/bundle.js +1 -0
- package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
- package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
- package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
- package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
- package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
- package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
- package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
- package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
- package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
- package/dist/routes/computer-use/fanout-records.js +10 -0
- package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
- package/dist/routes/computer-use/participant-model.js +2 -1
- package/dist/routes/computer-use/plan.js +2 -1
- package/dist/routes/computer-use/single-bundle.d.ts +3 -0
- package/dist/routes/computer-use/single-bundle.js +4 -0
- package/dist/routes/computer-use/types.d.ts +7 -0
- package/dist/routes/scripted/plan.js +2 -2
- package/dist/routes/shared-world/plan.js +3 -2
- package/dist/routes/terminal/live-sandbox.d.ts +2 -0
- package/dist/routes/terminal/live-sandbox.js +8 -3
- package/dist/routes/terminal/plan.js +51 -8
- package/dist/routes/terminal/session.js +8 -1
- package/dist/run/bundle.d.ts +2 -1
- package/dist/run/cost-summary.js +9 -4
- package/dist/run/live-items.d.ts +20 -2
- package/dist/run/live-items.js +23 -14
- package/dist/run/pricing.d.ts +11 -0
- package/dist/run/pricing.js +18 -5
- package/dist/run/reclaim-outcomes.d.ts +4 -1
- package/dist/run/reclaim-outcomes.js +8 -6
- package/dist/run/reclaim.d.ts +4 -1
- package/dist/run/reclaim.js +81 -34
- package/dist/run/sandbox-creates.d.ts +7 -4
- package/dist/run/sandbox-creates.js +17 -9
- package/dist/run/sandbox-receipts.d.ts +13 -5
- package/dist/run/sandbox-receipts.js +18 -6
- package/dist/run/streams.d.ts +7 -0
- package/dist/study/composition-rules.js +11 -10
- package/dist/study/config.js +4 -1
- package/dist/study/doctor.js +1 -1
- package/dist/study/keys.d.ts +3 -0
- package/dist/study/keys.js +2 -0
- package/dist/study/parse/comms.js +1 -20
- package/dist/study/parse/execution.js +35 -3
- package/dist/study/parse/values.d.ts +5 -0
- package/dist/study/parse/values.js +22 -0
- package/dist/study/plan-base.js +6 -1
- package/dist/study/plan-types.d.ts +5 -2
- package/dist/study/requirements.js +7 -2
- package/dist/study/routing.d.ts +1 -1
- package/dist/study/routing.js +1 -1
- package/dist/study/study-fields.d.ts +7 -1
- package/dist/study/study-fields.js +10 -0
- package/dist/study/types.d.ts +12 -1
- package/dist/study/validation.d.ts +8 -0
- package/dist/study/validation.js +31 -2
- package/dist/substrates/detached.d.ts +5 -0
- package/dist/substrates/detached.js +9 -0
- package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
- package/dist/substrates/e2b/cdp-network-capture.js +194 -0
- package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
- package/dist/substrates/e2b/cdp-probe.js +98 -34
- package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
- package/dist/substrates/e2b/desktop-cdp.js +87 -5
- package/dist/substrates/e2b/desktop-resources.d.ts +4 -2
- package/dist/substrates/e2b/desktop-resources.js +2 -2
- package/dist/substrates/e2b/peer-install.js +1 -1
- package/dist/substrates/e2b/sandbox.d.ts +3 -0
- package/dist/substrates/e2b/sandbox.js +5 -4
- package/dist/substrates/modal/image.d.ts +9 -0
- package/dist/substrates/modal/image.js +36 -0
- package/dist/substrates/modal/sandbox.d.ts +26 -0
- package/dist/substrates/modal/sandbox.js +367 -0
- package/dist/substrates/modal/sdk.d.ts +112 -0
- package/dist/substrates/modal/sdk.js +61 -0
- package/dist/verify/network.d.ts +10 -0
- package/dist/verify/network.js +162 -0
- package/dist/verify/pages.js +1 -16
- package/dist/verify/verify.js +8 -2
- package/docs/architecture/terminal-product-route.md +1 -1
- package/docs/contracts/run-bundle.md +90 -3
- package/docs/contracts/schemas.md +6 -5
- package/package.json +8 -2
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// of swallowing an exit code; the TypeScript side turns that into a participant warning that names the
|
|
16
16
|
// consequence ("url/text criteria will read as never measured").
|
|
17
17
|
import { shellQuote } from "../shell.js";
|
|
18
|
+
import { NETWORK_CAPTURE_PY } from "./cdp-network-capture.js";
|
|
18
19
|
/**
|
|
19
20
|
* The probe itself. Kept as one string so the shipped command is exactly what the tests execute
|
|
20
21
|
* (tests/substrates/e2b/cdp-probe.test.ts runs it under the real python3 against a real
|
|
@@ -272,17 +273,20 @@ def browser_ws_url(port):
|
|
|
272
273
|
info = json.loads(response.read().decode("utf-8"))
|
|
273
274
|
return str(info.get("webSocketDebuggerUrl") or "")
|
|
274
275
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
276
|
+
${NETWORK_CAPTURE_PY}
|
|
277
|
+
def hold(port, page, request, network):
|
|
278
|
+
"""Hold the launch page and every page target Chrome opens later, for as long as this process
|
|
279
|
+
lives, over one browser-level socket with flattened sessions. With an emulation request, the
|
|
280
|
+
launch page is attached by id and reloaded so scripts that read the UA at load see it; a target
|
|
281
|
+
that appears later is attached the moment it exists, sent the same overrides, and reloaded once
|
|
282
|
+
after its first real navigation commits (touch emulation reaches a document only when it loads
|
|
283
|
+
under it). Without one, nothing is emulated and nothing reloads. With a network request, every
|
|
284
|
+
held page also reports its traffic to the events file (NETWORK_CAPTURE_PY).
|
|
281
285
|
Two things this loop must never do (measured on a real desktop, 2026-09-04): pause new targets
|
|
282
286
|
(waitForDebuggerOnStart) and wait for their replies. A popup a participant taps open shares its
|
|
283
287
|
opener's renderer, answers Emulation commands only once it runs, and a loop blocked on that
|
|
284
|
-
reply never resumed the tab (it loaded forever) and never reached the next one.
|
|
285
|
-
sent without waiting; replies, errors included, are logged as they arrive.
|
|
288
|
+
reply never resumed the tab (it loaded forever) and never reached the next one. Commands to a
|
|
289
|
+
later target are sent without waiting; replies, errors included, are logged as they arrive.
|
|
286
290
|
Prints one JSON line per attached target; the first line is the announce the lane reads."""
|
|
287
291
|
launch_id = str(page.get("id") or "")
|
|
288
292
|
try:
|
|
@@ -295,15 +299,36 @@ def hold(port, page, request):
|
|
|
295
299
|
if not session_id:
|
|
296
300
|
print(json.dumps({"unavailable": "Target.attachToTarget failed for the launch page"}), flush=True)
|
|
297
301
|
return
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
log, capture = start_capture(ws, session_id, network) if network is not None else (None, None)
|
|
303
|
+
reason = "the holder stopped"
|
|
304
|
+
try:
|
|
305
|
+
applied, failure = apply_over(ws, request, session_id, reload=True) if request else ([], None)
|
|
306
|
+
held = failure is None and (bool(request) or log is not None)
|
|
307
|
+
announce = {"applied": applied, "held": held, "targetId": launch_id}
|
|
308
|
+
if capture is not None:
|
|
309
|
+
announce["network"] = capture
|
|
310
|
+
if failure:
|
|
311
|
+
announce["unavailable"] = failure
|
|
312
|
+
print(json.dumps(announce), flush=True)
|
|
313
|
+
if not held:
|
|
314
|
+
reason = failure or "nothing to hold"
|
|
315
|
+
return
|
|
316
|
+
reason = follow_targets(ws, launch_id, request, log)
|
|
317
|
+
except Exception as error:
|
|
318
|
+
reason = "%s: %s" % (type(error).__name__, error)
|
|
319
|
+
raise
|
|
320
|
+
finally:
|
|
321
|
+
if log is not None:
|
|
322
|
+
log.close(reason)
|
|
323
|
+
|
|
324
|
+
def follow_targets(ws, launch_id, request, log):
|
|
325
|
+
"""The holder's loop: attach every later page target, log replies and hand each event to the
|
|
326
|
+
capture log. Returns why it ended."""
|
|
303
327
|
auto = ws.call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": False, "flatten": True})
|
|
304
328
|
if not isinstance(auto, dict) or "error" in auto:
|
|
305
|
-
print(json.dumps({"autoAttach": False, "unavailable": "Target.setAutoAttach failed;
|
|
329
|
+
print(json.dumps({"autoAttach": False, "unavailable": "Target.setAutoAttach failed; the holder does not reach later tabs"}), flush=True)
|
|
306
330
|
ws.sock.settimeout(None)
|
|
331
|
+
covered = {launch_id}
|
|
307
332
|
pending = {}
|
|
308
333
|
# Sessions of later targets that still owe one reload: touch emulation reaches a document only
|
|
309
334
|
# when it loads under the override (measured: a popup's first document reported
|
|
@@ -314,11 +339,12 @@ def hold(port, page, request):
|
|
|
314
339
|
try:
|
|
315
340
|
message = ws.events.pop(0) if ws.events else ws.recv()
|
|
316
341
|
except Exception as error:
|
|
317
|
-
|
|
318
|
-
|
|
342
|
+
reason = "%s: %s" % (type(error).__name__, error)
|
|
343
|
+
print(json.dumps({"holderExit": reason}), flush=True)
|
|
344
|
+
return reason
|
|
319
345
|
if message is None:
|
|
320
346
|
print(json.dumps({"holderExit": "browser socket closed"}), flush=True)
|
|
321
|
-
return
|
|
347
|
+
return "browser socket closed"
|
|
322
348
|
reply_id = message.get("id")
|
|
323
349
|
session = message.get("sessionId")
|
|
324
350
|
if reply_id in pending:
|
|
@@ -336,6 +362,8 @@ def hold(port, page, request):
|
|
|
336
362
|
continue
|
|
337
363
|
method = message.get("method")
|
|
338
364
|
params = message.get("params") or {}
|
|
365
|
+
if log is not None and method:
|
|
366
|
+
log.event(method, params, session)
|
|
339
367
|
if method == "Page.frameNavigated" and session in reload_owed:
|
|
340
368
|
frame = params.get("frame") or {}
|
|
341
369
|
url = str(frame.get("url") or "")
|
|
@@ -351,35 +379,50 @@ def hold(port, page, request):
|
|
|
351
379
|
target_id = str(info.get("targetId") or "")
|
|
352
380
|
if info.get("type") == "page" and target_id and target_id not in covered:
|
|
353
381
|
covered.add(target_id)
|
|
354
|
-
messages = emulation_messages(request, reload=False)
|
|
382
|
+
messages = emulation_messages(request, reload=False) if request else []
|
|
355
383
|
for m in messages:
|
|
356
384
|
pending[ws.send(m["method"], m.get("params"), new_session)] = m["method"]
|
|
385
|
+
sent = [m["method"] for m in messages]
|
|
386
|
+
# After the overrides, so a later tab's emulation timing is what it was without capture.
|
|
387
|
+
if log is not None:
|
|
388
|
+
log.session(new_session)
|
|
389
|
+
pending[ws.send("Network.enable", NETWORK_ENABLE, new_session)] = "Network.enable"
|
|
390
|
+
sent.append("Network.enable")
|
|
357
391
|
pending[ws.send("Page.enable", {}, new_session)] = "Page.enable"
|
|
358
392
|
url = str(info.get("url") or "")
|
|
359
|
-
if
|
|
393
|
+
if not request:
|
|
394
|
+
print(json.dumps({"attached": target_id, "sent": sent}), flush=True)
|
|
395
|
+
elif url and not url.startswith("about:"):
|
|
360
396
|
pending[ws.send("Page.reload", {}, new_session)] = "Page.reload"
|
|
361
|
-
print(json.dumps({"attached": target_id, "sent":
|
|
397
|
+
print(json.dumps({"attached": target_id, "sent": sent + ["Page.reload"]}), flush=True)
|
|
362
398
|
else:
|
|
363
399
|
reload_owed[new_session] = target_id
|
|
364
400
|
# Two ways to learn the first real navigation committed, whichever answers first:
|
|
365
401
|
# Page.frameNavigated (if Page.enable landed before the commit) or the page's own
|
|
366
402
|
# location.href (if it did not).
|
|
367
403
|
pending[ws.send("Runtime.evaluate", {"expression": "location.href", "returnByValue": True}, new_session)] = "href"
|
|
368
|
-
print(json.dumps({"attached": target_id, "sent":
|
|
404
|
+
print(json.dumps({"attached": target_id, "sent": sent, "reloadAfterNavigation": True}), flush=True)
|
|
369
405
|
if params.get("waitingForDebugger"):
|
|
370
406
|
ws.send("Runtime.runIfWaitingForDebugger", {}, new_session)
|
|
371
407
|
|
|
408
|
+
def emit(answer):
|
|
409
|
+
"""Print one answer line stamped with this sandbox's clock. CDP wallTime uses the same clock, so
|
|
410
|
+
a turn mark taken from it compares with request times without host skew, and the reading needs
|
|
411
|
+
nothing from the page, so a page too busy to answer still leaves a mark."""
|
|
412
|
+
answer["now"] = time.time()
|
|
413
|
+
print(json.dumps(answer))
|
|
414
|
+
|
|
372
415
|
def main():
|
|
373
416
|
args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
|
374
417
|
mode = args.get("mode") or "state"
|
|
375
418
|
port = resolve_port(args)
|
|
376
419
|
if mode == "port":
|
|
377
|
-
|
|
420
|
+
emit({"cdpPort": port})
|
|
378
421
|
return
|
|
379
422
|
try:
|
|
380
423
|
pages = list_pages(port)
|
|
381
424
|
except Exception as error:
|
|
382
|
-
|
|
425
|
+
emit({"unavailable": "CDP endpoint 127.0.0.1:%d/json unreachable (%s)" % (port, type(error).__name__)})
|
|
383
426
|
return
|
|
384
427
|
page = select_page(pages, args)
|
|
385
428
|
# A listing with targets but no http page is usually an instant: a reload in flight, a tab
|
|
@@ -396,7 +439,7 @@ def main():
|
|
|
396
439
|
break
|
|
397
440
|
page = select_page(pages, args)
|
|
398
441
|
if page is None:
|
|
399
|
-
|
|
442
|
+
emit({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)})
|
|
400
443
|
return
|
|
401
444
|
# Chrome omits webSocketDebuggerUrl from /json while another DevTools client is attached to the
|
|
402
445
|
# page, or for a moment after one detaches; the page socket URL is still /devtools/page/<id>.
|
|
@@ -406,29 +449,29 @@ def main():
|
|
|
406
449
|
if mode == "emulate":
|
|
407
450
|
applied, failure = emulate(ws_url, args.get("emulation") or {}) if ws_url else (None, "the page has no socket")
|
|
408
451
|
if failure is not None:
|
|
409
|
-
|
|
452
|
+
emit({"unavailable": failure, "applied": applied or []})
|
|
410
453
|
return
|
|
411
|
-
|
|
454
|
+
emit({"applied": applied, "targetId": str(page.get("id") or "")})
|
|
412
455
|
return
|
|
413
456
|
if mode == "hold":
|
|
414
457
|
# Prints its announce line, then stays attached (and attaches to every later page target)
|
|
415
458
|
# until killed.
|
|
416
|
-
hold(port, page, args.get("emulation") or
|
|
459
|
+
hold(port, page, args.get("emulation") or None, args.get("network"))
|
|
417
460
|
return
|
|
418
461
|
if mode == "fidelity":
|
|
419
462
|
result = evaluate(ws_url, FIDELITY_EXPRESSION) if ws_url else None
|
|
420
463
|
if not isinstance(result, dict):
|
|
421
|
-
|
|
464
|
+
emit({"unavailable": "Runtime.evaluate over the page socket returned nothing"})
|
|
422
465
|
return
|
|
423
|
-
|
|
466
|
+
emit({"fidelity": result, "targetId": str(page.get("id") or "")})
|
|
424
467
|
return
|
|
425
468
|
if mode == "geometry":
|
|
426
469
|
result = evaluate(ws_url, GEOMETRY_EXPRESSION) if ws_url else None
|
|
427
470
|
if not isinstance(result, dict):
|
|
428
|
-
|
|
471
|
+
emit({"unavailable": "Runtime.evaluate over the page socket returned nothing"})
|
|
429
472
|
return
|
|
430
473
|
result["targetId"] = str(page.get("id") or "")
|
|
431
|
-
|
|
474
|
+
emit(result)
|
|
432
475
|
return
|
|
433
476
|
url = str(page.get("url") or "")
|
|
434
477
|
title = str(page.get("title") or "")
|
|
@@ -440,7 +483,7 @@ def main():
|
|
|
440
483
|
title = result["title"] if isinstance(result.get("title"), str) else title
|
|
441
484
|
text = result["text"] if isinstance(result.get("text"), str) else ""
|
|
442
485
|
scroll_y = result["scrollY"] if isinstance(result.get("scrollY"), (int, float)) else None
|
|
443
|
-
|
|
486
|
+
emit({"url": url, "title": title, "text": text, "scrollY": scroll_y, "targetId": str(page.get("id") or "")})
|
|
444
487
|
|
|
445
488
|
main()
|
|
446
489
|
`;
|
|
@@ -457,6 +500,8 @@ export function chromeCdpProbeCommand(args) {
|
|
|
457
500
|
payload.prefer = args.prefer;
|
|
458
501
|
if (args.emulation !== undefined)
|
|
459
502
|
payload.emulation = args.emulation;
|
|
503
|
+
if (args.network !== undefined)
|
|
504
|
+
payload.network = args.network;
|
|
460
505
|
return `python3 -c ${shellQuote(CHROME_CDP_PROBE_PY)} ${shellQuote(JSON.stringify(payload))}`;
|
|
461
506
|
}
|
|
462
507
|
/**
|
|
@@ -481,10 +526,17 @@ export function parseChromeCdpProbeOutput(stdout) {
|
|
|
481
526
|
const applied = Array.isArray(record.applied)
|
|
482
527
|
? record.applied.filter((item) => typeof item === "string")
|
|
483
528
|
: undefined;
|
|
529
|
+
const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
530
|
+
const now = numberOr(record.now);
|
|
531
|
+
const network = networkOf(record.network);
|
|
484
532
|
if (typeof record.unavailable === "string") {
|
|
485
|
-
return {
|
|
533
|
+
return {
|
|
534
|
+
unavailable: record.unavailable,
|
|
535
|
+
...(applied === undefined ? {} : { applied }),
|
|
536
|
+
...(network === undefined ? {} : { network }),
|
|
537
|
+
...(now === undefined ? {} : { now }),
|
|
538
|
+
};
|
|
486
539
|
}
|
|
487
|
-
const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
488
540
|
const box = (value, keys) => {
|
|
489
541
|
if (!value || typeof value !== "object")
|
|
490
542
|
return undefined;
|
|
@@ -549,5 +601,17 @@ export function parseChromeCdpProbeOutput(stdout) {
|
|
|
549
601
|
: {}),
|
|
550
602
|
...(browserWindow === undefined ? {} : { browserWindow }),
|
|
551
603
|
...(viewport === undefined ? {} : { viewport }),
|
|
604
|
+
...(network === undefined ? {} : { network }),
|
|
605
|
+
...(now === undefined ? {} : { now }),
|
|
552
606
|
};
|
|
553
607
|
}
|
|
608
|
+
function networkOf(value) {
|
|
609
|
+
if (!value || typeof value !== "object")
|
|
610
|
+
return undefined;
|
|
611
|
+
const source = value;
|
|
612
|
+
if (typeof source.startedAt === "number" && Number.isFinite(source.startedAt))
|
|
613
|
+
return { startedAt: source.startedAt };
|
|
614
|
+
if (typeof source.unavailable === "string")
|
|
615
|
+
return { unavailable: source.unavailable };
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ChromeNetworkCaptureRequest } from "./cdp-network-capture.js";
|
|
2
|
+
import { type ChromeCdpProbeResult, type ChromeMobileEmulationRequest } from "./cdp-probe.js";
|
|
2
3
|
import type { RunDesktopGeometry } from "../../run/streams.js";
|
|
3
4
|
import type { E2BDesktopSandbox } from "./sdk.js";
|
|
4
5
|
/**
|
|
@@ -53,6 +54,12 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
|
|
|
53
54
|
/** Called once, with the reason, on the first probe that could not read the page. */
|
|
54
55
|
onUnavailable?: (reason: string) => void;
|
|
55
56
|
drift?: ChromeEmulationDrift | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Called with undefined when a probe starts, then with the sandbox clock the probe printed
|
|
59
|
+
* (ChromeCdpProbeResult.now), so the caller holds the time of the latest read and never a
|
|
60
|
+
* stale one from a probe that failed.
|
|
61
|
+
*/
|
|
62
|
+
onClock?: (now: number | undefined) => void;
|
|
56
63
|
}): () => Promise<{
|
|
57
64
|
url?: string;
|
|
58
65
|
title?: string;
|
|
@@ -65,13 +72,36 @@ export declare const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhon
|
|
|
65
72
|
* Apply mobile emulation to the participant's launch page and read back what the page reports.
|
|
66
73
|
* Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
|
|
67
74
|
* the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
|
|
68
|
-
* (the emulation was applied; only the proof is missing).
|
|
75
|
+
* (the emulation was applied; only the proof is missing). With `network`, the same holder also
|
|
76
|
+
* captures requests, and a capture that could not start is reported in `network` without failing
|
|
77
|
+
* the emulation.
|
|
69
78
|
*/
|
|
70
79
|
export declare function applyMobileEmulation(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, request: ChromeMobileEmulationRequest, options?: {
|
|
71
80
|
targetId?: string | undefined;
|
|
81
|
+
network?: ChromeNetworkCaptureRequest | undefined;
|
|
72
82
|
}): Promise<{
|
|
73
83
|
fidelity: NonNullable<RunDesktopGeometry["fidelity"]>;
|
|
74
84
|
warnings: string[];
|
|
75
85
|
targetId?: string;
|
|
76
86
|
holderName: string;
|
|
87
|
+
/** The holder's capture status, when `network` was passed and the holder said. */
|
|
88
|
+
network?: ChromeCdpProbeResult["network"];
|
|
77
89
|
}>;
|
|
90
|
+
/**
|
|
91
|
+
* Start a holder that records the launch page's requests, and every later tab's, to
|
|
92
|
+
* `network.eventsPath`, for a participant without mobile emulation; applyMobileEmulation passes
|
|
93
|
+
* the request to the holder it starts. Sends no Emulation command and no reload. Returns the
|
|
94
|
+
* holder's process name, which readNetworkCaptureStart reads.
|
|
95
|
+
*/
|
|
96
|
+
export declare function startNetworkCapture(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, network: ChromeNetworkCaptureRequest): Promise<string>;
|
|
97
|
+
/**
|
|
98
|
+
* What the capture holder announced: when capture started on the launch page, on the sandbox
|
|
99
|
+
* clock, or why it did not. Reads the holder's log up to 6 times, half a second apart by
|
|
100
|
+
* `desktop.wait`, and reports a holder that printed nothing as unavailable.
|
|
101
|
+
*/
|
|
102
|
+
export declare function readNetworkCaptureStart(desktop: E2BDesktopSandbox, requestTimeoutMs: number, holderName: string): Promise<NonNullable<ChromeCdpProbeResult["network"]>>;
|
|
103
|
+
/**
|
|
104
|
+
* The capture holder's events file as text: whole lines only, from at most the first 32 MiB.
|
|
105
|
+
* Throws when the sandbox cannot stream files or the file cannot be read.
|
|
106
|
+
*/
|
|
107
|
+
export declare function readNetworkEvents(desktop: E2BDesktopSandbox, eventsPath: string, requestTimeoutMs: number): Promise<string>;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Chromium DevTools on a hosted E2B desktop, reached from inside the sandbox: browser state reads
|
|
2
|
-
// and mobile emulation.
|
|
3
1
|
import { chromeCdpProbeCommand, parseChromeCdpProbeOutput, } from "./cdp-probe.js";
|
|
4
2
|
import { failureTail, toErrorMessage } from "../../evidence/redaction.js";
|
|
5
3
|
import { readDetachedLog, startDetachedProcess } from "../detached.js";
|
|
@@ -18,7 +16,7 @@ import { e2bShell } from "./shell.js";
|
|
|
18
16
|
* url/text criteria are not being measured, instead of letting the funnel report 0/N.
|
|
19
17
|
*/
|
|
20
18
|
export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, options = {}) {
|
|
21
|
-
const { targetId, onUnavailable, drift } = options;
|
|
19
|
+
const { targetId, onUnavailable, drift, onClock } = options;
|
|
22
20
|
let reported = false;
|
|
23
21
|
let drifted = false;
|
|
24
22
|
const checkedTargets = new Set(drift === undefined ? [] : [drift.emulatedTargetId]);
|
|
@@ -68,6 +66,7 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
|
|
|
68
66
|
: `the participant drove a page target other than the emulated launch tab and that page reports a ${fidelity.innerWidth} px viewport where ${drift.expectedWidth} px was requested (DPR ${fidelity.devicePixelRatio}); the mobile user agent and touch events are browser-wide, the viewport override was not re-applied to it`);
|
|
69
67
|
};
|
|
70
68
|
return async () => {
|
|
69
|
+
onClock?.(undefined);
|
|
71
70
|
let result;
|
|
72
71
|
try {
|
|
73
72
|
result = await shell.run(chromeCdpProbeCommand({
|
|
@@ -85,6 +84,7 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
|
|
|
85
84
|
return unavailable(`probe exited ${result.exitCode}: ${failureTail(result.stderr || result.stdout)}`);
|
|
86
85
|
}
|
|
87
86
|
const parsed = parseChromeCdpProbeOutput(result.stdout);
|
|
87
|
+
onClock?.(parsed.now);
|
|
88
88
|
if (parsed.unavailable !== undefined)
|
|
89
89
|
return unavailable(parsed.unavailable);
|
|
90
90
|
if (parsed.targetId !== undefined)
|
|
@@ -103,16 +103,19 @@ export const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_
|
|
|
103
103
|
* Apply mobile emulation to the participant's launch page and read back what the page reports.
|
|
104
104
|
* Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
|
|
105
105
|
* the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
|
|
106
|
-
* (the emulation was applied; only the proof is missing).
|
|
106
|
+
* (the emulation was applied; only the proof is missing). With `network`, the same holder also
|
|
107
|
+
* captures requests, and a capture that could not start is reported in `network` without failing
|
|
108
|
+
* the emulation.
|
|
107
109
|
*/
|
|
108
110
|
export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint, request, options = {}) {
|
|
109
|
-
const { targetId } = options;
|
|
111
|
+
const { targetId, network } = options;
|
|
110
112
|
const command = (mode) => chromeCdpProbeCommand({
|
|
111
113
|
...endpoint,
|
|
112
114
|
...(targetId === undefined ? {} : { targetId }),
|
|
113
115
|
prefer: "pinned",
|
|
114
116
|
mode,
|
|
115
117
|
emulation: request,
|
|
118
|
+
...(mode === "hold" && network !== undefined ? { network } : {}),
|
|
116
119
|
});
|
|
117
120
|
const shell = e2bShell(desktop);
|
|
118
121
|
const read = async () => {
|
|
@@ -178,6 +181,7 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
|
|
|
178
181
|
userAgent: request.userAgent,
|
|
179
182
|
};
|
|
180
183
|
const emulatedTargetId = applied.targetId ?? fidelityRead.targetId;
|
|
184
|
+
const capture = applied.network === undefined ? {} : { network: applied.network };
|
|
181
185
|
if (fidelityRead.fidelity === undefined) {
|
|
182
186
|
warnings.push(`Mobile emulation was applied but the page's own report could not be read (${fidelityRead.unavailable ?? "no fidelity read"}); desktopGeometry.fidelity carries the request without a resolved block.`);
|
|
183
187
|
return {
|
|
@@ -185,6 +189,7 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
|
|
|
185
189
|
warnings,
|
|
186
190
|
holderName,
|
|
187
191
|
...(emulatedTargetId === undefined ? {} : { targetId: emulatedTargetId }),
|
|
192
|
+
...capture,
|
|
188
193
|
};
|
|
189
194
|
}
|
|
190
195
|
const resolved = { ...fidelityRead.fidelity, source: "cdp" };
|
|
@@ -207,5 +212,82 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
|
|
|
207
212
|
warnings,
|
|
208
213
|
holderName,
|
|
209
214
|
...(emulatedTargetId === undefined ? {} : { targetId: emulatedTargetId }),
|
|
215
|
+
...capture,
|
|
210
216
|
};
|
|
211
217
|
}
|
|
218
|
+
/** The holder stops writing its events file at 32 MiB, so a read stops there too. */
|
|
219
|
+
const NETWORK_EVENTS_MAX_BYTES = 32 * 1024 * 1024;
|
|
220
|
+
/** How many times the capture holder's log is read for its announce, and how far apart. */
|
|
221
|
+
const NETWORK_ANNOUNCE_READS = 6;
|
|
222
|
+
const NETWORK_ANNOUNCE_EVERY_MS = 500;
|
|
223
|
+
/**
|
|
224
|
+
* Start a holder that records the launch page's requests, and every later tab's, to
|
|
225
|
+
* `network.eventsPath`, for a participant without mobile emulation; applyMobileEmulation passes
|
|
226
|
+
* the request to the holder it starts. Sends no Emulation command and no reload. Returns the
|
|
227
|
+
* holder's process name, which readNetworkCaptureStart reads.
|
|
228
|
+
*/
|
|
229
|
+
export async function startNetworkCapture(desktop, requestTimeoutMs, endpoint, network) {
|
|
230
|
+
const holderName = `network-capture-${Date.now().toString(36)}`;
|
|
231
|
+
await startDetachedProcess(e2bShell(desktop), {
|
|
232
|
+
name: holderName,
|
|
233
|
+
command: chromeCdpProbeCommand({ ...endpoint, prefer: "pinned", mode: "hold", network }),
|
|
234
|
+
requestTimeoutMs,
|
|
235
|
+
});
|
|
236
|
+
return holderName;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* What the capture holder announced: when capture started on the launch page, on the sandbox
|
|
240
|
+
* clock, or why it did not. Reads the holder's log up to 6 times, half a second apart by
|
|
241
|
+
* `desktop.wait`, and reports a holder that printed nothing as unavailable.
|
|
242
|
+
*/
|
|
243
|
+
export async function readNetworkCaptureStart(desktop, requestTimeoutMs, holderName) {
|
|
244
|
+
const shell = e2bShell(desktop);
|
|
245
|
+
for (let attempt = 0; attempt < NETWORK_ANNOUNCE_READS; attempt += 1) {
|
|
246
|
+
if (attempt > 0)
|
|
247
|
+
await desktop.wait(NETWORK_ANNOUNCE_EVERY_MS).catch(() => undefined);
|
|
248
|
+
const log = await readDetachedLog(shell, holderName, requestTimeoutMs).catch(() => "");
|
|
249
|
+
const line = log.split("\n").find((candidate) => candidate.trim().startsWith("{"));
|
|
250
|
+
if (line === undefined)
|
|
251
|
+
continue;
|
|
252
|
+
const announced = parseChromeCdpProbeOutput(line);
|
|
253
|
+
return (announced.network ?? {
|
|
254
|
+
unavailable: announced.unavailable ?? "the holder announced without starting capture",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
unavailable: `the in-sandbox holder printed nothing within ${(NETWORK_ANNOUNCE_READS * NETWORK_ANNOUNCE_EVERY_MS) / 1000} s of the desktop settling`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The capture holder's events file as text: whole lines only, from at most the first 32 MiB.
|
|
263
|
+
* Throws when the sandbox cannot stream files or the file cannot be read.
|
|
264
|
+
*/
|
|
265
|
+
export async function readNetworkEvents(desktop, eventsPath, requestTimeoutMs) {
|
|
266
|
+
if (desktop.files.read === undefined)
|
|
267
|
+
throw new Error("this E2B SDK cannot stream a sandbox file to the host");
|
|
268
|
+
const stream = await desktop.files.read(eventsPath, {
|
|
269
|
+
format: "stream",
|
|
270
|
+
requestTimeoutMs,
|
|
271
|
+
streamIdleTimeoutMs: requestTimeoutMs,
|
|
272
|
+
signal: AbortSignal.timeout(requestTimeoutMs),
|
|
273
|
+
});
|
|
274
|
+
const reader = stream.getReader();
|
|
275
|
+
const chunks = [];
|
|
276
|
+
let total = 0;
|
|
277
|
+
try {
|
|
278
|
+
while (total < NETWORK_EVENTS_MAX_BYTES) {
|
|
279
|
+
const { done, value } = await reader.read();
|
|
280
|
+
if (done)
|
|
281
|
+
break;
|
|
282
|
+
const chunk = Buffer.from(value.subarray(0, NETWORK_EVENTS_MAX_BYTES - total));
|
|
283
|
+
chunks.push(chunk);
|
|
284
|
+
total += chunk.length;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
await reader.cancel().catch(() => undefined);
|
|
289
|
+
}
|
|
290
|
+
const bytes = Buffer.concat(chunks);
|
|
291
|
+
// A line still being written, or one the cap cut, is dropped.
|
|
292
|
+
return bytes.subarray(0, bytes.lastIndexOf(0x0a) + 1).toString("utf8");
|
|
293
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { E2BDesktopSandbox } from "./sdk.js";
|
|
2
2
|
import { type DesktopResources } from "../../run/pricing.js";
|
|
3
|
+
/** Where an allocation's size came from: E2B's report, or the size humanish asked Modal for. */
|
|
4
|
+
export type DesktopResourceSource = "e2b.getInfo" | "modal.create";
|
|
3
5
|
export type DesktopResourceObservation = {
|
|
4
6
|
resources: DesktopResources;
|
|
5
|
-
source:
|
|
7
|
+
source: DesktopResourceSource;
|
|
6
8
|
} | {
|
|
7
9
|
reason: "metadata_unavailable" | "metadata_invalid" | "metadata_timeout";
|
|
8
10
|
};
|
|
9
11
|
/** Called once after the caller owns and journals the handle. Never throws or delays cleanup
|
|
10
12
|
* beyond the bounded wait. Persist only resource quantities, never the raw info/connection. */
|
|
11
|
-
export declare function observeDesktopResources(desktop: Pick<E2BDesktopSandbox, "getInfo">, timeoutMs?: number): Promise<DesktopResourceObservation>;
|
|
13
|
+
export declare function observeDesktopResources(desktop: Pick<E2BDesktopSandbox, "getInfo">, timeoutMs?: number, source?: DesktopResourceSource): Promise<DesktopResourceObservation>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isDesktopResources } from "../../run/pricing.js";
|
|
2
2
|
/** Called once after the caller owns and journals the handle. Never throws or delays cleanup
|
|
3
3
|
* beyond the bounded wait. Persist only resource quantities, never the raw info/connection. */
|
|
4
|
-
export async function observeDesktopResources(desktop, timeoutMs = 1_000) {
|
|
4
|
+
export async function observeDesktopResources(desktop, timeoutMs = 1_000, source = "e2b.getInfo") {
|
|
5
5
|
const controller = new AbortController();
|
|
6
6
|
let timer;
|
|
7
7
|
try {
|
|
@@ -16,7 +16,7 @@ export async function observeDesktopResources(desktop, timeoutMs = 1_000) {
|
|
|
16
16
|
});
|
|
17
17
|
const resources = { cpuCount: info?.cpuCount, memoryMiB: info?.memoryMB };
|
|
18
18
|
return isDesktopResources(resources)
|
|
19
|
-
? { resources, source
|
|
19
|
+
? { resources, source }
|
|
20
20
|
: { reason: "metadata_invalid" };
|
|
21
21
|
}),
|
|
22
22
|
new Promise((resolve) => {
|
|
@@ -139,7 +139,7 @@ export function desktopPeerAdvice(install, spec = "@e2b/desktop") {
|
|
|
139
139
|
return {
|
|
140
140
|
where: ", and humanish is running from a source checkout",
|
|
141
141
|
command: "pnpm install",
|
|
142
|
-
advice: spec === "@e2b/desktop"
|
|
142
|
+
advice: spec === "@e2b/desktop" || spec === "modal"
|
|
143
143
|
? "Run `pnpm install` in the humanish checkout, which installs it as a dev dependency."
|
|
144
144
|
: `Set ${spec} in the humanish checkout's package.json, then run \`pnpm install\` there.`,
|
|
145
145
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SandboxProviderId } from "../../run/sandbox-receipts.js";
|
|
1
2
|
import type { PreparedOutputRoot } from "../../run/contained-output.js";
|
|
2
3
|
import { type DesktopReleaseResult, type OwnedDesktopAllocation } from "../desktop-session.js";
|
|
3
4
|
import type { StudyConfig } from "../../study/types.js";
|
|
@@ -13,6 +14,8 @@ interface E2BSandboxReceiptTarget {
|
|
|
13
14
|
}
|
|
14
15
|
export interface E2BSandboxRequest {
|
|
15
16
|
module: E2BDesktopModule;
|
|
17
|
+
/** Who `module` creates sandboxes on; receipts and owner lines name it. Defaults to e2b. */
|
|
18
|
+
provider?: SandboxProviderId;
|
|
16
19
|
options: E2BDesktopCreateOptions;
|
|
17
20
|
retry?: TransientRetryHooks;
|
|
18
21
|
/**
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// journal at that point, so a startup error that quotes it is scrubbed from every run file.
|
|
10
10
|
import { redactText, toErrorMessage } from "../../evidence/redaction.js";
|
|
11
11
|
import { beginSandboxCreate, recordSandboxOwnerOnce, SandboxCreateRefusedError, sandboxOwnerTags, } from "../../run/sandbox-creates.js";
|
|
12
|
-
import { appendSandboxOwner, appendSandboxReceipt } from "../../run/sandbox-receipts.js";
|
|
12
|
+
import { appendSandboxOwner, appendSandboxReceipt, } from "../../run/sandbox-receipts.js";
|
|
13
13
|
import { ownDesktopAllocation, } from "../desktop-session.js";
|
|
14
14
|
import { e2bAccount } from "./connection.js";
|
|
15
15
|
import { E2B_DEBUG_KILL_DETAIL, E2BDesktopStartupError, e2bDebugMode, isSandboxNotFoundError, observeDesktopAllocation, } from "./sdk.js";
|
|
@@ -23,9 +23,10 @@ export function acquireE2BShellSandbox(request) {
|
|
|
23
23
|
}
|
|
24
24
|
async function acquire(request, template) {
|
|
25
25
|
const { module } = request;
|
|
26
|
+
const provider = request.provider ?? "e2b";
|
|
26
27
|
const target = request.receipt;
|
|
27
28
|
// Refused before any provider call once the run is stopping.
|
|
28
|
-
const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId);
|
|
29
|
+
const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId, provider);
|
|
29
30
|
// The receipts written when the guard reported an id, before desktop startup. A startup error
|
|
30
31
|
// can quote the id and the create may never return, so the journal holds it first.
|
|
31
32
|
const reported = new Map();
|
|
@@ -35,7 +36,7 @@ async function acquire(request, template) {
|
|
|
35
36
|
// journal records them first, so reclaim knows which tags to search for.
|
|
36
37
|
const owner = target === null ? undefined : sandboxOwnerTags(target.root);
|
|
37
38
|
if (target !== null && owner !== undefined)
|
|
38
|
-
await recordSandboxOwnerOnce(target.root, () => appendSandboxOwner(target.root, owner));
|
|
39
|
+
await recordSandboxOwnerOnce(target.root, provider, () => appendSandboxOwner(target.root, owner, provider));
|
|
39
40
|
const options = owner === undefined
|
|
40
41
|
? request.options
|
|
41
42
|
: { ...request.options, metadata: { ...request.options.metadata, ...owner } };
|
|
@@ -46,7 +47,7 @@ async function acquire(request, template) {
|
|
|
46
47
|
return appendSandboxReceipt(root, {
|
|
47
48
|
at: new Date(now()).toISOString(),
|
|
48
49
|
laneId: participantId,
|
|
49
|
-
provider
|
|
50
|
+
provider,
|
|
50
51
|
sandboxId,
|
|
51
52
|
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
52
53
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ModalClient, ModalImage } from "./sdk.js";
|
|
2
|
+
/** The sandbox account commands run as, matching E2B's `user` and its home. */
|
|
3
|
+
export declare const MODAL_SANDBOX_USER: {
|
|
4
|
+
readonly name: "user";
|
|
5
|
+
readonly uid: 1000;
|
|
6
|
+
readonly home: "/home/user";
|
|
7
|
+
};
|
|
8
|
+
/** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
|
|
9
|
+
export declare function modalShellImage(client: ModalClient): ModalImage;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const DEBIAN_BASE = "debian@sha256:7792b1f7702a86946cd518db72b6a407302c3e9bc1635634368b878189e8221c";
|
|
2
|
+
const DEBIAN_SNAPSHOT = "20260923T083042Z";
|
|
3
|
+
const SECURITY_SNAPSHOT = "20260922T123247Z";
|
|
4
|
+
/** The sandbox account commands run as, matching E2B's `user` and its home. */
|
|
5
|
+
export const MODAL_SANDBOX_USER = { name: "user", uid: 1000, home: "/home/user" };
|
|
6
|
+
const SNAPSHOT_SOURCES = `printf 'Types: deb\\nURIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/\\nSuites: trixie trixie-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\\nCheck-Valid-Until: no\\n\\nTypes: deb\\nURIs: http://snapshot.debian.org/archive/debian-security/${SECURITY_SNAPSHOT}/\\nSuites: trixie-security\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\\nCheck-Valid-Until: no\\n'` +
|
|
7
|
+
" > /etc/apt/sources.list.d/humanish.sources";
|
|
8
|
+
// What the terminal route's steps call: the Node bootstrap's prerequisites (curl, coreutils, tar,
|
|
9
|
+
// gzip, sudo), git for product installs, and python3 and unzip, which E2B's stock template has.
|
|
10
|
+
const SHELL_PACKAGES = [
|
|
11
|
+
"bash",
|
|
12
|
+
"ca-certificates",
|
|
13
|
+
"curl",
|
|
14
|
+
"git",
|
|
15
|
+
"gzip",
|
|
16
|
+
"less",
|
|
17
|
+
"procps",
|
|
18
|
+
"python3",
|
|
19
|
+
"sudo",
|
|
20
|
+
"tar",
|
|
21
|
+
"unzip",
|
|
22
|
+
"xz-utils",
|
|
23
|
+
];
|
|
24
|
+
/** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
|
|
25
|
+
export function modalShellImage(client) {
|
|
26
|
+
const { name, uid, home } = MODAL_SANDBOX_USER;
|
|
27
|
+
// prose-check: script
|
|
28
|
+
return client.images
|
|
29
|
+
.fromRegistry(DEBIAN_BASE)
|
|
30
|
+
.dockerfileCommands([
|
|
31
|
+
"ENV DEBIAN_FRONTEND=noninteractive",
|
|
32
|
+
`RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && ${SNAPSHOT_SOURCES} && apt-get update && apt-get install -y --no-install-recommends ${SHELL_PACKAGES.join(" ")} && rm -rf /var/lib/apt/lists/*`,
|
|
33
|
+
`RUN useradd --uid ${uid} --create-home --home-dir ${home} --shell /bin/bash ${name} && printf '${name} ALL=(ALL) NOPASSWD:ALL\\n' > /etc/sudoers.d/${name} && chmod 0440 /etc/sudoers.d/${name}`,
|
|
34
|
+
`WORKDIR ${home}`,
|
|
35
|
+
]);
|
|
36
|
+
}
|