agent-device-lab 0.0.0-stage → 0.3.1

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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +150 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +63 -4
@@ -0,0 +1,38 @@
1
+ const EXIT_CODES = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 };
2
+ let terminating = false;
3
+ /**
4
+ * Call before a command exits by itself: when a termination signal is already being handled, wait for
5
+ * its handler to finish and exit with the signal's code instead of the command's own.
6
+ */
7
+ export async function holdIfTerminating() {
8
+ if (terminating)
9
+ await new Promise(() => undefined);
10
+ }
11
+ /**
12
+ * Commands that run a Lab in this process (`run`, `scan --project`) own a browser and services. On the
13
+ * first SIGINT, SIGTERM or SIGHUP, run `cleanup` (close the lab: stops what it started) and exit with the
14
+ * conventional 128 + signal code. A second signal exits at once with 1 and says what may be left running.
15
+ * Returns a function that removes the handlers.
16
+ */
17
+ export function onTermination(cleanup, opts = {}) {
18
+ let received;
19
+ const handler = (signal) => {
20
+ if (received) {
21
+ process.stderr.write(`agentlab: ${signal} again; exiting now. The browser and the services this run started may still be running; ` +
22
+ `run \`agentlab clean\`${opts.stateDir ? ` (state: ${opts.stateDir})` : ''} to stop what is left.\n`);
23
+ process.exit(1);
24
+ }
25
+ received = signal;
26
+ terminating = true;
27
+ process.stderr.write(`agentlab: ${signal} received; stopping what this run started (send it again to exit immediately)\n`);
28
+ void cleanup(signal)
29
+ .catch((err) => process.stderr.write(`agentlab: cleanup failed: ${err instanceof Error ? err.message : String(err)}\n`))
30
+ .finally(() => process.exit(EXIT_CODES[signal] ?? 1));
31
+ };
32
+ const signals = Object.keys(EXIT_CODES);
33
+ for (const s of signals)
34
+ process.on(s, handler);
35
+ return () => { for (const s of signals)
36
+ process.off(s, handler); };
37
+ }
38
+ //# sourceMappingURL=signals.js.map
@@ -0,0 +1,125 @@
1
+ import { consequentialWord } from './explore-safety.js';
2
+ import { redactSecrets } from './feed.js';
3
+ import { formatChanges } from './format.js';
4
+ // The structured, replay-oriented log of what an agent did in a session (kept in memory, non-child sessions
5
+ // only) and a compact copy of its last observations. Bundles (bundle.ts) and replay (replay.ts) read it.
6
+ // Pure apart from the log's own state, so it is unit-tested without a browser.
7
+ export const SECRET_MASK = '‹secret›';
8
+ export const ACTION_LOG_LIMIT = 500;
9
+ export const OBSERVATION_LOG_LIMIT = 10;
10
+ const CONTROLS_PER_OBSERVATION = 40;
11
+ const CHANGE_LINES = 10;
12
+ const VALUE_MAX = 2000;
13
+ /** Shorter typed secrets are not tracked for redaction passes: replacing a 2-character string everywhere would wreck the text. */
14
+ const MIN_TRACKED_SECRET = 4;
15
+ /** Fallback when an action failed before the field could be inspected: names that suggest a secret. */
16
+ const SECRETISH_NAME = /pass|secret|token|otp|pin|cvv|cvc/i;
17
+ /** The path of a route (query and hash dropped, they often carry tokens). */
18
+ export function routePath(route) {
19
+ return route === undefined ? undefined : route.split(/[?#]/)[0] || '/';
20
+ }
21
+ const recordedTarget = (c) => c ? { role: c.role, name: c.name, ...(c.context ? { context: c.context } : {}) } : undefined;
22
+ export function summariseObservation(o) {
23
+ return {
24
+ gen: o.gen, route: routePath(o.route) ?? '/', title: o.title.slice(0, 120), ...(o.dialog ? { dialog: o.dialog } : {}),
25
+ headings: o.headings.slice(0, 20),
26
+ controls: o.controls.slice(0, CONTROLS_PER_OBSERVATION).map((c) => ({
27
+ role: c.role, name: c.name, ...(c.context ? { context: c.context } : {}), ...(c.value !== undefined ? { value: c.value } : {}),
28
+ })),
29
+ omitted: Math.max(0, o.controls.length - CONTROLS_PER_OBSERVATION) + o.omitted,
30
+ };
31
+ }
32
+ /**
33
+ * One agent action as it is recorded. `secret` is what the Lab knows (a password-like field was filled or
34
+ * typed into); a masked field the observation shows as "••••" and, when the action failed before the field
35
+ * was inspected, a secret-looking name are treated the same. A value is never kept for a secret field.
36
+ */
37
+ export function recordAction(input) {
38
+ const { request: r, before, result, index } = input;
39
+ const target = before && r.ref ? before.controls.find((c) => c.ref === r.ref) : undefined;
40
+ const to = before && r.toRef ? before.controls.find((c) => c.ref === r.toRef) : undefined;
41
+ const typed = r.action === 'fill' || (r.action === 'press' && r.key !== undefined && r.key.split('+').at(-1).length === 1);
42
+ const secret = typed && (input.secret || target?.value === '••••' || (result.outcome === 'error' && !!target && SECRETISH_NAME.test(target.name)));
43
+ const args = {};
44
+ let truncated = false;
45
+ if (r.value !== undefined) {
46
+ if (secret)
47
+ args.value = SECRET_MASK;
48
+ else {
49
+ args.value = r.value.slice(0, VALUE_MAX);
50
+ truncated = r.value.length > VALUE_MAX;
51
+ }
52
+ }
53
+ if (r.key !== undefined)
54
+ args.key = secret ? SECRET_MASK : r.key;
55
+ if (r.values !== undefined)
56
+ args.values = r.values.slice(0, 100).map((v) => v.slice(0, VALUE_MAX));
57
+ for (const k of ['direction', 'amount', 'dx', 'dy', 'files', 'tab', 'path'])
58
+ if (r[k] !== undefined)
59
+ args[k] = r[k];
60
+ const after = result.observation ?? before;
61
+ const t = recordedTarget(target);
62
+ const tt = recordedTarget(to);
63
+ return {
64
+ index, at: (input.now ?? new Date()).toISOString(), actor: 'agent', action: r.action,
65
+ ...(t ? { target: t } : {}), ...(tt ? { toTarget: tt } : {}), args,
66
+ ...(secret ? { secret: true } : {}), ...(truncated ? { truncated: true } : {}),
67
+ ...(before ? { routeBefore: routePath(before.route), ...(before.dialog ? { dialogBefore: before.dialog } : {}) } : {}),
68
+ ...(after ? { routeAfter: routePath(after.route), ...(after.dialog ? { dialogAfter: after.dialog } : {}) } : {}),
69
+ outcome: result.outcome,
70
+ ...(result.error ? { error: { code: result.error.code, message: redactSecrets(result.error.message).slice(0, 300) } } : {}),
71
+ changes: result.changes ? formatChanges(result.changes).slice(0, CHANGE_LINES).map((l) => redactSecrets(l).slice(0, 200)) : [],
72
+ newFindings: result.newFindings.map((f) => f.id),
73
+ consequential: [t?.name, tt?.name].some((n) => n !== undefined && !!consequentialWord(n)),
74
+ };
75
+ }
76
+ export class ActionLog {
77
+ entries = [];
78
+ omitted = 0;
79
+ next = 1;
80
+ recent = [];
81
+ secrets = new Set();
82
+ typedKeys = '';
83
+ /** Route of the first observation of the session. */
84
+ startRoute = '/';
85
+ /** The agent's next action. Values typed into password-like fields are remembered (in memory only) for redaction. */
86
+ agent(input) {
87
+ const entry = recordAction({ ...input, index: this.next });
88
+ if (entry.secret && input.request.action === 'fill' && input.request.value !== undefined)
89
+ this.remember(input.request.value);
90
+ if (entry.secret && input.request.action === 'press' && input.request.key) {
91
+ this.typedKeys += input.request.key;
92
+ this.remember(this.typedKeys);
93
+ }
94
+ else
95
+ this.typedKeys = '';
96
+ this.push(entry);
97
+ }
98
+ person(description, now = new Date()) {
99
+ this.typedKeys = '';
100
+ this.push({ index: this.next, at: now.toISOString(), actor: 'person', description: redactSecrets(description).slice(0, 200) });
101
+ }
102
+ observation(o) {
103
+ this.recent.push(summariseObservation(o));
104
+ if (this.recent.length > OBSERVATION_LOG_LIMIT)
105
+ this.recent.shift();
106
+ }
107
+ push(entry) {
108
+ this.next++;
109
+ this.entries.push(entry);
110
+ if (this.entries.length > ACTION_LOG_LIMIT) {
111
+ this.entries.shift();
112
+ this.omitted++;
113
+ }
114
+ }
115
+ remember(value) {
116
+ if (value.length >= MIN_TRACKED_SECRET)
117
+ this.secrets.add(value);
118
+ }
119
+ get actions() { return structuredClone(this.entries); }
120
+ get actionsOmitted() { return this.omitted; }
121
+ get observations() { return structuredClone(this.recent); }
122
+ /** Values the agent typed into password-like fields. For redaction passes only: never logged, never returned to a caller outside the process. */
123
+ secretValues() { return [...this.secrets]; }
124
+ }
125
+ //# sourceMappingURL=action-log.js.map
@@ -0,0 +1,175 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { dirname, relative } from 'node:path';
4
+ import { LabError } from './schema.js';
5
+ /** Load and check the saved state. Throws auth_missing, or auth_invalid after invalidating an unusable file. */
6
+ export function loadAuthState(policy) {
7
+ if (!existsSync(policy.file)) {
8
+ throw new LabError('auth_missing', `No saved sign-in state at ${policy.file}`, {
9
+ hint: 'Start a fresh session, sign in, then run `agentlab auth save` (MCP: auth_save).',
10
+ });
11
+ }
12
+ const mode = statSync(policy.file).mode & 0o777;
13
+ if (mode & 0o077) {
14
+ throw new LabError('auth_invalid', `Saved sign-in state ${policy.file} is readable by other users (mode ${mode.toString(8)})`, {
15
+ hint: `Run \`chmod 600 ${policy.file}\`, or \`agentlab auth clear\` and save it again.`,
16
+ });
17
+ }
18
+ let state;
19
+ try {
20
+ state = JSON.parse(readFileSync(policy.file, 'utf8'));
21
+ if (!Array.isArray(state.cookies) || !Array.isArray(state.origins))
22
+ throw new Error('missing cookies/origins');
23
+ }
24
+ catch (err) {
25
+ invalidate(policy);
26
+ throw new LabError('auth_invalid', `Saved sign-in state was unreadable (${err.message.split('\n')[0]}) and has been removed`, {
27
+ hint: 'Start a fresh session, sign in, then save the state again.',
28
+ });
29
+ }
30
+ if (isExpired(state)) {
31
+ invalidate(policy);
32
+ throw new LabError('auth_invalid', 'Saved sign-in state had expired (every cookie is past its expiry) and has been removed', {
33
+ hint: 'Start a fresh session, sign in, then save the state again.',
34
+ });
35
+ }
36
+ return state;
37
+ }
38
+ /** The environment variable CI mode reads a sign-in state from (JSON or base64 JSON). */
39
+ export const AUTH_STATE_ENV = 'AGENTLAB_AUTH_STATE';
40
+ /**
41
+ * A sign-in state from an environment variable value (Playwright storageState as JSON or base64 JSON), with
42
+ * the same shape and expiry checks as `loadAuthState`. It never touches the disk, and no message here
43
+ * quotes the value (a JSON parser's own message would).
44
+ */
45
+ export function authStateFromEnv(value) {
46
+ const text = value.trim();
47
+ const bad = (why) => {
48
+ throw new LabError('auth_invalid', `${AUTH_STATE_ENV} is not a usable sign-in state: ${why}`, {
49
+ hint: `Set it to a Playwright storageState as JSON or base64-encoded JSON (\`agentlab auth save\` writes one; base64 -w0 encodes it).`,
50
+ });
51
+ };
52
+ if (!text)
53
+ return bad('it is empty');
54
+ let raw;
55
+ try {
56
+ raw = JSON.parse(text.startsWith('{') ? text : Buffer.from(text, 'base64').toString('utf8'));
57
+ }
58
+ catch {
59
+ return bad('it is neither JSON nor base64-encoded JSON');
60
+ }
61
+ const obj = raw;
62
+ if (!obj || typeof obj !== 'object' || !Array.isArray(obj.cookies) || !Array.isArray(obj.origins))
63
+ return bad('it needs "cookies" and "origins" lists');
64
+ const cookies = obj.cookies.map((c, i) => {
65
+ const k = c;
66
+ if (!k || typeof k !== 'object' || typeof k.name !== 'string' || typeof k.value !== 'string' || typeof k.domain !== 'string') {
67
+ return bad(`cookie ${i + 1} needs a string name, value and domain`);
68
+ }
69
+ return { ...k, path: typeof k.path === 'string' ? k.path : '/', expires: typeof k.expires === 'number' ? k.expires : -1 };
70
+ });
71
+ const origins = obj.origins.map((o, i) => {
72
+ const k = o;
73
+ if (!k || typeof k !== 'object' || typeof k.origin !== 'string')
74
+ return bad(`origin ${i + 1} needs a string origin`);
75
+ if (k.localStorage !== undefined && !Array.isArray(k.localStorage))
76
+ return bad(`origin ${i + 1}: localStorage must be a list`);
77
+ return { ...k, localStorage: (k.localStorage ?? []) };
78
+ });
79
+ const state = { ...obj, cookies, origins };
80
+ if (isExpired(state))
81
+ return bad('every cookie is past its expiry');
82
+ return state;
83
+ }
84
+ /**
85
+ * The values of a state that must never be printed or written (cookie values and storage values), for the
86
+ * caller's redaction set. Values shorter than 6 characters are left out: they would match ordinary text.
87
+ */
88
+ export function authStateSecrets(state) {
89
+ const out = new Set();
90
+ for (const c of state.cookies)
91
+ if (typeof c.value === 'string' && c.value.length >= 6)
92
+ out.add(c.value);
93
+ for (const o of state.origins)
94
+ for (const e of o.localStorage ?? [])
95
+ if (typeof e.value === 'string' && e.value.length >= 6)
96
+ out.add(e.value);
97
+ return [...out];
98
+ }
99
+ /** Whether a saved state exists and is usable, without changing anything (unlike `loadAuthState`, which removes an unusable file). */
100
+ export function savedStateProblem(policy, root) {
101
+ const s = authStatus(policy, root);
102
+ if (!s.exists)
103
+ return `no saved sign-in state at ${policy.file}`;
104
+ if (s.problem)
105
+ return `saved sign-in state ${policy.file} is ${s.problem}`;
106
+ if (s.ownerOnly === false)
107
+ return `saved sign-in state ${policy.file} is readable by other users (mode ${s.mode})`;
108
+ if (s.expired)
109
+ return `saved sign-in state ${policy.file} has expired`;
110
+ return undefined;
111
+ }
112
+ /** Remove a saved state that has proved unusable. */
113
+ export function invalidate(policy) {
114
+ rmSync(policy.file, { force: true });
115
+ }
116
+ /**
117
+ * Write state owner-only (0600) under a 0700 directory, atomically. Refuses when the project is a git
118
+ * repository that would track the file.
119
+ */
120
+ export function saveAuthState(policy, root, state) {
121
+ if (gitIgnores(root, policy.file) === false) {
122
+ throw new LabError('auth_not_ignored', `git would track ${relative(root, policy.file)}, so the sign-in state was not saved`, {
123
+ hint: 'Run `agentlab init` (it adds .agentlab/ to .gitignore) or add the file to .gitignore yourself.',
124
+ });
125
+ }
126
+ const dir = dirname(policy.file);
127
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
128
+ chmodSync(dir, 0o700);
129
+ const tmp = `${policy.file}.${process.pid}.tmp`;
130
+ writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
131
+ chmodSync(tmp, 0o600);
132
+ renameSync(tmp, policy.file);
133
+ return { cookies: state.cookies.length, origins: state.origins.length, file: policy.file };
134
+ }
135
+ export function authStatus(policy, root) {
136
+ const status = { file: policy.file, exists: existsSync(policy.file) };
137
+ const ignored = gitIgnores(root, policy.file);
138
+ if (ignored !== undefined)
139
+ status.ignored = ignored;
140
+ if (!status.exists)
141
+ return status;
142
+ const mode = statSync(policy.file).mode & 0o777;
143
+ status.mode = mode.toString(8);
144
+ status.ownerOnly = !(mode & 0o077);
145
+ try {
146
+ const state = JSON.parse(readFileSync(policy.file, 'utf8'));
147
+ status.cookies = state.cookies?.length ?? 0;
148
+ status.origins = state.origins?.length ?? 0;
149
+ status.expired = isExpired(state);
150
+ }
151
+ catch {
152
+ status.problem = 'unreadable JSON';
153
+ }
154
+ return status;
155
+ }
156
+ function isExpired(state) {
157
+ const now = Date.now() / 1000;
158
+ const hasStorage = state.origins.some((o) => o.localStorage?.length);
159
+ const session = state.cookies.filter((c) => !(c.expires > 0));
160
+ return !hasStorage && !session.length && state.cookies.length > 0 && state.cookies.every((c) => c.expires > 0 && c.expires < now);
161
+ }
162
+ /** true/false from `git check-ignore`; undefined outside a git repository or without git. */
163
+ export function gitIgnores(root, file) {
164
+ try {
165
+ execFileSync('git', ['check-ignore', '-q', '--no-index', file], { cwd: root, stdio: 'ignore' });
166
+ return true;
167
+ }
168
+ catch (err) {
169
+ const status = err.status;
170
+ if (status === 1)
171
+ return false;
172
+ return undefined;
173
+ }
174
+ }
175
+ //# sourceMappingURL=auth.js.map