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.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +150 -2
- package/assets/dashboard/app.css +238 -0
- package/assets/dashboard/app.js +976 -0
- package/assets/dashboard/index.html +120 -0
- package/bin/agentlab.js +8 -0
- package/dist/cli/ci.js +443 -0
- package/dist/cli/flow.js +127 -0
- package/dist/cli/main.js +855 -0
- package/dist/cli/signals.js +38 -0
- package/dist/core/action-log.js +125 -0
- package/dist/core/auth.js +175 -0
- package/dist/core/bundle.js +432 -0
- package/dist/core/checks.js +137 -0
- package/dist/core/ci.js +389 -0
- package/dist/core/commands.js +366 -0
- package/dist/core/control.js +217 -0
- package/dist/core/detectors.js +392 -0
- package/dist/core/devices.js +83 -0
- package/dist/core/doctor.js +203 -0
- package/dist/core/explore-safety.js +110 -0
- package/dist/core/extract.js +1445 -0
- package/dist/core/feed.js +493 -0
- package/dist/core/findings.js +179 -0
- package/dist/core/format.js +311 -0
- package/dist/core/init.js +607 -0
- package/dist/core/lab.js +1979 -0
- package/dist/core/migrate.js +35 -0
- package/dist/core/observation.js +161 -0
- package/dist/core/ownership.js +142 -0
- package/dist/core/process-group.js +206 -0
- package/dist/core/process-identity.js +56 -0
- package/dist/core/profile.js +816 -0
- package/dist/core/project-runner.js +359 -0
- package/dist/core/replay.js +303 -0
- package/dist/core/reproduction.js +36 -0
- package/dist/core/scan-policy.js +156 -0
- package/dist/core/scan-report.js +145 -0
- package/dist/core/scan.js +455 -0
- package/dist/core/schema.js +39 -0
- package/dist/core/services.js +134 -0
- package/dist/core/steps.js +80 -0
- package/dist/core/sweep.js +138 -0
- package/dist/core/trace-sanitize.js +641 -0
- package/dist/core/url-redact.js +25 -0
- package/dist/core/versions.js +29 -0
- package/dist/core/zip.js +264 -0
- package/dist/daemon/main.js +160 -0
- package/dist/daemon/state.js +93 -0
- package/dist/dashboard/open.js +25 -0
- package/dist/dashboard/server.js +366 -0
- package/dist/mcp/server.js +89 -0
- package/package.json +63 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { parseProfile } from './profile.js';
|
|
2
|
+
import { CONTRACT_VERSIONS } from './versions.js';
|
|
3
|
+
/**
|
|
4
|
+
* Migrate a raw profile object. An invalid profile throws `invalid_profile`, a newer one
|
|
5
|
+
* `profile_too_new` (both from `parseProfile`, so the rules are the ones `start` applies).
|
|
6
|
+
* A current profile is returned unchanged.
|
|
7
|
+
*
|
|
8
|
+
* schemaVersion 1 → 2: the `web` section becomes `services.web` (same keys: v2 infers an http check
|
|
9
|
+
* from the service url and `readiness.path`, so nothing is added), `app` points at it, and every other
|
|
10
|
+
* top-level field is kept in place.
|
|
11
|
+
*/
|
|
12
|
+
export function migrateProfile(raw) {
|
|
13
|
+
const parsed = parseProfile(raw, '/migrate/agentlab.json'); // throws on invalid or too new
|
|
14
|
+
const obj = raw;
|
|
15
|
+
const to = CONTRACT_VERSIONS.profile;
|
|
16
|
+
if (parsed.schemaVersion === to)
|
|
17
|
+
return { changed: false, from: to, to, profile: obj, notes: [] };
|
|
18
|
+
const notes = ['schemaVersion 1 → 2'];
|
|
19
|
+
const profile = {};
|
|
20
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
21
|
+
if (key === 'schemaVersion')
|
|
22
|
+
profile.schemaVersion = to;
|
|
23
|
+
else if (key === 'web') {
|
|
24
|
+
profile.services = { web: structuredClone(value) };
|
|
25
|
+
profile.app = { service: 'web' };
|
|
26
|
+
notes.push('"web" became "services.web"', 'added "app": {"service": "web"}');
|
|
27
|
+
}
|
|
28
|
+
else if (key === 'app')
|
|
29
|
+
notes.push('dropped "app": schemaVersion 1 ignored it, and the application is now services.web');
|
|
30
|
+
else
|
|
31
|
+
profile[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return { changed: true, from: 1, to, profile, notes };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=migrate.js.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { redactUrlSecrets } from './url-redact.js';
|
|
2
|
+
import { SCHEMA_VERSION } from './schema.js';
|
|
3
|
+
/** Turn raw page data into a normalised observation with layout flags. Pure. */
|
|
4
|
+
export function buildObservation(raw, ctx) {
|
|
5
|
+
const { pathname, search, hash } = new URL(raw.url);
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: SCHEMA_VERSION,
|
|
8
|
+
sessionId: ctx.sessionId,
|
|
9
|
+
gen: ctx.gen,
|
|
10
|
+
docId: raw.docId,
|
|
11
|
+
// Tokens in the query or fragment never leave the page: see url-redact.ts.
|
|
12
|
+
url: redactUrlSecrets(raw.url),
|
|
13
|
+
route: redactUrlSecrets(pathname + search + hash),
|
|
14
|
+
title: raw.title,
|
|
15
|
+
viewport: raw.viewport,
|
|
16
|
+
scroll: raw.scroll,
|
|
17
|
+
documentWidth: raw.documentWidth,
|
|
18
|
+
layoutViewportWidth: raw.layoutViewportWidth,
|
|
19
|
+
headings: raw.headings,
|
|
20
|
+
...(raw.dialog !== undefined ? { dialog: raw.dialog } : {}),
|
|
21
|
+
controls: raw.controls,
|
|
22
|
+
omitted: raw.omitted,
|
|
23
|
+
inert: raw.inert,
|
|
24
|
+
messages: raw.messages,
|
|
25
|
+
...(raw.focused ? { focused: raw.focused } : {}),
|
|
26
|
+
layout: layoutFlags(raw),
|
|
27
|
+
console: { errors: ctx.consoleErrors },
|
|
28
|
+
network: { failed: ctx.failedRequests },
|
|
29
|
+
findings: 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* First-slice layout signals, measured in CSS px. This is not the responsive scanner: it only reports
|
|
34
|
+
* document-level horizontal overflow and observed controls that extend past the layout viewport.
|
|
35
|
+
*/
|
|
36
|
+
export function layoutFlags(raw) {
|
|
37
|
+
const flags = [];
|
|
38
|
+
const vw = raw.viewport.width;
|
|
39
|
+
if (raw.documentWidth > vw + 1) {
|
|
40
|
+
flags.push({
|
|
41
|
+
kind: 'horizontal-overflow',
|
|
42
|
+
severity: 'medium',
|
|
43
|
+
key: 'overflow',
|
|
44
|
+
message: `document is ${raw.documentWidth}px wide, viewport is ${vw}px (page pans sideways)` +
|
|
45
|
+
(raw.layoutViewportWidth && raw.layoutViewportWidth > vw + 1 ? `; mobile browser widened the layout viewport to ${raw.layoutViewportWidth}px` : ''),
|
|
46
|
+
evidence: { documentWidth: raw.documentWidth, viewportWidth: vw, excessPx: raw.documentWidth - vw, ...(raw.layoutViewportWidth ? { layoutViewportWidth: raw.layoutViewportWidth } : {}) },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
for (const c of raw.controls) {
|
|
50
|
+
if (!c.clip)
|
|
51
|
+
continue;
|
|
52
|
+
const beyond = Math.min(c.clip.px, c.rect.w);
|
|
53
|
+
const hiddenShare = beyond / Math.max(c.rect.w, 1);
|
|
54
|
+
flags.push({
|
|
55
|
+
kind: 'control-clipped',
|
|
56
|
+
severity: hiddenShare >= 0.5 ? 'high' : 'medium',
|
|
57
|
+
key: `clipped:${c.ref}`,
|
|
58
|
+
ref: c.ref,
|
|
59
|
+
message: `${c.role} "${c.name}" extends ${c.clip.px}px past the ${c.clip.side} edge of the ${vw}px viewport` +
|
|
60
|
+
(hiddenShare >= 1 ? ' (entirely outside the initial view)' : ` (${Math.round(hiddenShare * 100)}% outside)`),
|
|
61
|
+
evidence: { left: c.rect.x, right: c.rect.x + c.rect.w, width: c.rect.w, viewportWidth: vw, side: c.clip.side, pastEdgePx: c.clip.px },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return flags;
|
|
65
|
+
}
|
|
66
|
+
const summary = (c) => ({ ref: c.ref, role: c.role, name: c.name });
|
|
67
|
+
const TRACKED = ['name', 'value', 'disabled', 'checked', 'pressed', 'expanded', 'selected', 'required', 'invalid'];
|
|
68
|
+
/** Compare two observations. Refs are identity; a new document resets the baseline. Pure. */
|
|
69
|
+
export function diffObservations(prev, next) {
|
|
70
|
+
const changes = {
|
|
71
|
+
added: [], removed: [], changed: [], rerendered: [], covered: 0, uncovered: 0,
|
|
72
|
+
messagesAdded: [], messagesRemoved: [], headingsAdded: [], headingsRemoved: [],
|
|
73
|
+
layoutAdded: [], layoutResolved: [], none: false,
|
|
74
|
+
};
|
|
75
|
+
if (!prev) {
|
|
76
|
+
changes.reset = { reason: 'no-baseline' };
|
|
77
|
+
return changes;
|
|
78
|
+
}
|
|
79
|
+
if (prev.route !== next.route)
|
|
80
|
+
changes.route = { from: prev.route, to: next.route };
|
|
81
|
+
if (prev.title !== next.title)
|
|
82
|
+
changes.title = { from: prev.title, to: next.title };
|
|
83
|
+
if (prev.docId !== next.docId) {
|
|
84
|
+
changes.reset = { reason: 'navigation' };
|
|
85
|
+
return changes;
|
|
86
|
+
}
|
|
87
|
+
if (prev.dialog !== next.dialog)
|
|
88
|
+
changes.dialog = { from: prev.dialog, to: next.dialog };
|
|
89
|
+
const before = new Map(prev.controls.map((c) => [c.ref, c]));
|
|
90
|
+
const after = new Map(next.controls.map((c) => [c.ref, c]));
|
|
91
|
+
const prevInert = new Map(prev.inert.map((c) => [c.ref, c]));
|
|
92
|
+
const nextInert = new Set(next.inert.map((c) => c.ref));
|
|
93
|
+
const appeared = [];
|
|
94
|
+
for (const [ref, c] of after) {
|
|
95
|
+
const old = before.get(ref);
|
|
96
|
+
if (!old) {
|
|
97
|
+
if (prevInert.has(ref))
|
|
98
|
+
changes.uncovered++;
|
|
99
|
+
else
|
|
100
|
+
appeared.push(c);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const fields = [];
|
|
104
|
+
for (const key of TRACKED) {
|
|
105
|
+
const a = old[key] ?? (key === 'value' || key === 'name' ? undefined : false);
|
|
106
|
+
const b = c[key] ?? (key === 'value' || key === 'name' ? undefined : false);
|
|
107
|
+
if (a !== b)
|
|
108
|
+
fields.push({ field: key, from: a, to: b });
|
|
109
|
+
}
|
|
110
|
+
if (fields.length)
|
|
111
|
+
changes.changed.push({ ...summary(c), fields });
|
|
112
|
+
}
|
|
113
|
+
// Gone from view: previously observed controls, plus controls that disappeared while covered by a dialog.
|
|
114
|
+
const vanished = [];
|
|
115
|
+
for (const [ref, c] of before) {
|
|
116
|
+
if (after.has(ref))
|
|
117
|
+
continue;
|
|
118
|
+
if (nextInert.has(ref))
|
|
119
|
+
changes.covered++;
|
|
120
|
+
else
|
|
121
|
+
vanished.push(summary(c));
|
|
122
|
+
}
|
|
123
|
+
for (const [ref, c] of prevInert)
|
|
124
|
+
if (!after.has(ref) && !nextInert.has(ref))
|
|
125
|
+
vanished.push(c);
|
|
126
|
+
// Re-render heuristic: a vanished and an appeared control with the same role and name are the same
|
|
127
|
+
// thing drawn again. Match by identity of role+name only, never by list position.
|
|
128
|
+
const pool = new Map();
|
|
129
|
+
for (const v of vanished) {
|
|
130
|
+
const key = `${v.role}\u0000${v.name}`;
|
|
131
|
+
pool.set(key, [...(pool.get(key) ?? []), v]);
|
|
132
|
+
}
|
|
133
|
+
for (const c of appeared) {
|
|
134
|
+
const match = pool.get(`${c.role}\u0000${c.name}`)?.shift();
|
|
135
|
+
if (match)
|
|
136
|
+
changes.rerendered.push({ ...summary(c), from: match.ref, to: c.ref });
|
|
137
|
+
else
|
|
138
|
+
changes.added.push(summary(c));
|
|
139
|
+
}
|
|
140
|
+
changes.removed = [...pool.values()].flat();
|
|
141
|
+
const msgKey = (m) => `${m.role}\u0000${m.text}`;
|
|
142
|
+
const prevMsgs = new Set(prev.messages.map(msgKey));
|
|
143
|
+
const nextMsgs = new Set(next.messages.map(msgKey));
|
|
144
|
+
changes.messagesAdded = next.messages.filter((m) => !prevMsgs.has(msgKey(m)));
|
|
145
|
+
changes.messagesRemoved = prev.messages.filter((m) => !nextMsgs.has(msgKey(m)));
|
|
146
|
+
changes.headingsAdded = next.headings.filter((h) => !prev.headings.includes(h));
|
|
147
|
+
changes.headingsRemoved = prev.headings.filter((h) => !next.headings.includes(h));
|
|
148
|
+
if (prev.focused !== next.focused)
|
|
149
|
+
changes.focus = { from: prev.focused, to: next.focused };
|
|
150
|
+
const prevFlags = new Set(prev.layout.map((f) => f.key));
|
|
151
|
+
const nextFlags = new Set(next.layout.map((f) => f.key));
|
|
152
|
+
changes.layoutAdded = next.layout.filter((f) => !prevFlags.has(f.key));
|
|
153
|
+
// A flag whose control merely left the observation (e.g. covered by a dialog) is not "resolved".
|
|
154
|
+
changes.layoutResolved = prev.layout.filter((f) => !nextFlags.has(f.key) && (!f.ref || after.has(f.ref)));
|
|
155
|
+
changes.none = !changes.route && !changes.title && !changes.dialog && !changes.added.length && !changes.removed.length &&
|
|
156
|
+
!changes.changed.length && !changes.rerendered.length && !changes.covered && !changes.uncovered && !changes.messagesAdded.length &&
|
|
157
|
+
!changes.messagesRemoved.length && !changes.headingsAdded.length && !changes.headingsRemoved.length &&
|
|
158
|
+
!changes.focus && !changes.layoutAdded.length && !changes.layoutResolved.length;
|
|
159
|
+
return changes;
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=observation.js.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describeMemberStop, stopGroup } from './process-group.js';
|
|
4
|
+
import { checkIdentity, identify } from './process-identity.js';
|
|
5
|
+
const ownedDir = (stateDir) => join(stateDir, 'owned');
|
|
6
|
+
const recordFile = (stateDir, sessionId) => join(ownedDir(stateDir), `${sessionId}.json`);
|
|
7
|
+
export function recordOwnership(stateDir, sessionId, services) {
|
|
8
|
+
mkdirSync(ownedDir(stateDir), { recursive: true, mode: 0o700 });
|
|
9
|
+
const record = { sessionId, owner: identify(process.pid), recordedAt: new Date().toISOString(), services };
|
|
10
|
+
const file = recordFile(stateDir, sessionId);
|
|
11
|
+
writeFileSync(file, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
12
|
+
chmodSync(file, 0o600);
|
|
13
|
+
}
|
|
14
|
+
export function releaseOwnership(stateDir, sessionId) {
|
|
15
|
+
rmSync(recordFile(stateDir, sessionId), { force: true });
|
|
16
|
+
}
|
|
17
|
+
/** Records in <stateDir>/owned; unreadable or malformed files are ignored. */
|
|
18
|
+
export function readOwnership(stateDir) {
|
|
19
|
+
let names;
|
|
20
|
+
try {
|
|
21
|
+
names = readdirSync(ownedDir(stateDir));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const n of names.filter((f) => f.endsWith('.json'))) {
|
|
28
|
+
try {
|
|
29
|
+
const r = JSON.parse(readFileSync(join(ownedDir(stateDir), n), 'utf8'));
|
|
30
|
+
if (typeof r.sessionId === 'string' && Array.isArray(r.services))
|
|
31
|
+
out.push(r);
|
|
32
|
+
}
|
|
33
|
+
catch { /* not a record */ }
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** An owner that is neither gone nor reused: a live session, or one whose identity cannot be checked. */
|
|
38
|
+
const ownerPresent = (r) => {
|
|
39
|
+
const c = checkIdentity(r.owner);
|
|
40
|
+
return c !== 'gone' && c !== 'reused';
|
|
41
|
+
};
|
|
42
|
+
/** Run names (directories under runs/) that a live owner's record names: never pruned. */
|
|
43
|
+
export function liveSessionIds(stateDir) {
|
|
44
|
+
return new Set(readOwnership(stateDir).filter(ownerPresent).map((r) => r.sessionId));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Stop recorded services the lab started: only owned process groups whose leader's identity still
|
|
48
|
+
* matches (SIGTERM, then SIGKILL after `graceMs` if the group has not emptied). If the leader (the
|
|
49
|
+
* shell wrapper) is gone, each recorded group member whose own identity still matches is signalled
|
|
50
|
+
* individually; any other process left in the group is reported, never signalled. A one-shot service's
|
|
51
|
+
* stop command is named for the person to run; its containers cannot be proved to still be the ones the
|
|
52
|
+
* lab started. Returns human-readable notes.
|
|
53
|
+
*/
|
|
54
|
+
export async function stopRecordedServices(records, opts = {}) {
|
|
55
|
+
const graceMs = opts.graceMs ?? 5000;
|
|
56
|
+
const notes = [];
|
|
57
|
+
const single = records.length === 1;
|
|
58
|
+
for (const s of [...records].reverse()) {
|
|
59
|
+
const label = single ? 'owned server' : `owned service "${s.name}"`;
|
|
60
|
+
if (!s.owned) {
|
|
61
|
+
notes.push(single ? 'reused server left running' : `service "${s.name}" was reused; left running`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (s.mode === 'oneshot') {
|
|
65
|
+
if (s.stopCommand)
|
|
66
|
+
notes.push(`${label} was started by a one-shot command; to stop it, run \`${s.stopCommand}\` in ${s.cwd}`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const check = checkIdentity(s.identity);
|
|
70
|
+
if (check === 'unverifiable') {
|
|
71
|
+
notes.push(`${label} pid ${s.pid ?? '?'} cannot be verified; not signalled (check it manually)`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const r = await stopGroup(s.identity, s.members ?? [], { graceMs });
|
|
75
|
+
if (r.via === 'group') {
|
|
76
|
+
notes.push(`stopped ${label} process group ${s.identity.pid}${r.graceful ? '' : ` (SIGKILL after ${graceMs}ms)`}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const detail = describeMemberStop(r, graceMs);
|
|
80
|
+
const lead = check === 'gone' ? `${label} had already exited` : `${label} pid ${s.pid} now belongs to another process; not signalled`;
|
|
81
|
+
notes.push(detail ? `${lead}; ${detail}` : lead);
|
|
82
|
+
}
|
|
83
|
+
return notes;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Clean up after sessions that died without stopping their services. A record whose owner is still the
|
|
87
|
+
* process that wrote it (a live session) is never touched, and neither is one whose owner cannot be
|
|
88
|
+
* verified. Returns notes; each handled record is removed.
|
|
89
|
+
*/
|
|
90
|
+
export async function reapOrphans(stateDir) {
|
|
91
|
+
const notes = [];
|
|
92
|
+
for (const r of readOwnership(stateDir)) {
|
|
93
|
+
if (ownerPresent(r))
|
|
94
|
+
continue;
|
|
95
|
+
const owned = r.services.filter((s) => s.owned);
|
|
96
|
+
if (owned.length) {
|
|
97
|
+
const stopped = await stopRecordedServices(owned);
|
|
98
|
+
notes.push(...stopped.map((n) => `session ${r.sessionId} ended without cleanup: ${n}`));
|
|
99
|
+
}
|
|
100
|
+
releaseOwnership(stateDir, r.sessionId);
|
|
101
|
+
}
|
|
102
|
+
return notes;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Delete the oldest runs beyond the `keep` most recent (0 keeps all), never one in `protect`. Returns
|
|
106
|
+
* the deleted run names. Run names sort chronologically (s-YYYYMMDDHHMMSS-xxxx).
|
|
107
|
+
*/
|
|
108
|
+
export function pruneRuns(stateDir, keep, protect) {
|
|
109
|
+
if (!(keep > 0))
|
|
110
|
+
return [];
|
|
111
|
+
const runs = join(stateDir, 'runs');
|
|
112
|
+
let names;
|
|
113
|
+
try {
|
|
114
|
+
names = readdirSync(runs).filter((n) => { try {
|
|
115
|
+
return statSync(join(runs, n)).isDirectory();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
} });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
names.sort();
|
|
125
|
+
const removed = [];
|
|
126
|
+
for (const n of names.slice(0, Math.max(0, names.length - keep))) {
|
|
127
|
+
if (protect.has(n))
|
|
128
|
+
continue;
|
|
129
|
+
rmSync(join(runs, n), { recursive: true, force: true });
|
|
130
|
+
removed.push(n);
|
|
131
|
+
}
|
|
132
|
+
return removed;
|
|
133
|
+
}
|
|
134
|
+
/** AGENTLAB_KEEP_RUNS, default 20; 0 keeps everything. */
|
|
135
|
+
export function keepRuns(env = process.env) {
|
|
136
|
+
const v = env.AGENTLAB_KEEP_RUNS;
|
|
137
|
+
if (v === undefined || v === '')
|
|
138
|
+
return 20;
|
|
139
|
+
const n = Number(v);
|
|
140
|
+
return Number.isInteger(n) && n >= 0 ? n : 20;
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=ownership.js.map
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { redactSecrets } from './feed.js';
|
|
4
|
+
import { checkIdentity, identify } from './process-identity.js';
|
|
5
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
const COMMAND_MAX = 100;
|
|
7
|
+
function shorten(text) {
|
|
8
|
+
const t = redactSecrets(text.replace(/\s+/g, ' ').trim());
|
|
9
|
+
return t.length > COMMAND_MAX ? `${t.slice(0, COMMAND_MAX)}…` : t;
|
|
10
|
+
}
|
|
11
|
+
/** Live (not zombie) processes whose process group is `pgid`, including the leader if it is still there. */
|
|
12
|
+
export function listGroup(pgid) {
|
|
13
|
+
if (!Number.isInteger(pgid) || pgid <= 0)
|
|
14
|
+
return [];
|
|
15
|
+
const out = [];
|
|
16
|
+
if (process.platform === 'linux') {
|
|
17
|
+
let names;
|
|
18
|
+
try {
|
|
19
|
+
names = readdirSync('/proc');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
for (const n of names) {
|
|
25
|
+
if (!/^\d+$/.test(n))
|
|
26
|
+
continue;
|
|
27
|
+
try {
|
|
28
|
+
const stat = readFileSync(`/proc/${n}/stat`, 'utf8');
|
|
29
|
+
// Fields after the last ')': state, ppid, pgrp.
|
|
30
|
+
const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
31
|
+
if (Number(f[2]) !== pgid || f[0] === 'Z')
|
|
32
|
+
continue;
|
|
33
|
+
let command = '';
|
|
34
|
+
try {
|
|
35
|
+
command = readFileSync(`/proc/${n}/cmdline`, 'utf8').split('\0').join(' ');
|
|
36
|
+
}
|
|
37
|
+
catch { /* exited */ }
|
|
38
|
+
if (!command.trim())
|
|
39
|
+
command = stat.slice(stat.indexOf('(') + 1, stat.lastIndexOf(')'));
|
|
40
|
+
out.push({ pid: Number(n), command: shorten(command) });
|
|
41
|
+
}
|
|
42
|
+
catch { /* exited while scanning */ }
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const text = execFileSync('ps', ['-axo', 'pid=,pgid=,stat=,command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
48
|
+
for (const line of text.split('\n')) {
|
|
49
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
50
|
+
if (m && Number(m[2]) === pgid && !m[3].startsWith('Z'))
|
|
51
|
+
out.push({ pid: Number(m[1]), command: shorten(m[4]) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* ps unavailable */ }
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The members of the leader's process group right now, each with its own identity (the leader itself is
|
|
59
|
+
* not included). Only while the leader is verifiably the recorded process: then the pgid is its group.
|
|
60
|
+
*/
|
|
61
|
+
export function snapshotMembers(leader) {
|
|
62
|
+
if (!leader || checkIdentity(leader) !== 'same')
|
|
63
|
+
return undefined;
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const p of listGroup(leader.pid)) {
|
|
66
|
+
if (p.pid === leader.pid)
|
|
67
|
+
continue;
|
|
68
|
+
const id = identify(p.pid);
|
|
69
|
+
if (id)
|
|
70
|
+
out.push({ ...id, command: p.command });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const keyOf = (m) => `${m.pid}:${m.startTime}`;
|
|
75
|
+
/** A new snapshot added to what was recorded before; members that have since exited are dropped. */
|
|
76
|
+
export function mergeMembers(previous, snapshot) {
|
|
77
|
+
const merged = new Map();
|
|
78
|
+
for (const m of previous)
|
|
79
|
+
if (checkIdentity(m) === 'same')
|
|
80
|
+
merged.set(keyOf(m), m);
|
|
81
|
+
for (const m of snapshot)
|
|
82
|
+
merged.set(keyOf(m), m);
|
|
83
|
+
return [...merged.values()];
|
|
84
|
+
}
|
|
85
|
+
/** Still running: the identity matches and the process is not a zombie. */
|
|
86
|
+
function live(m) {
|
|
87
|
+
if (checkIdentity(m) !== 'same')
|
|
88
|
+
return false;
|
|
89
|
+
return !isZombie(m.pid);
|
|
90
|
+
}
|
|
91
|
+
/** Is this pid a zombie? (A killed child that nobody has reaped yet is no longer running.) */
|
|
92
|
+
function isZombie(pid) {
|
|
93
|
+
if (process.platform === 'linux') {
|
|
94
|
+
try {
|
|
95
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
96
|
+
return stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z');
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().startsWith('Z');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export const describeProcess = (p) => `${p.command || '?'} (pid ${p.pid})`;
|
|
110
|
+
/**
|
|
111
|
+
* Stop what a service started. With the leader verified (`same`) the whole group is signalled (SIGTERM,
|
|
112
|
+
* then SIGKILL after `graceMs` if it has not emptied). With the leader gone, each recorded member whose
|
|
113
|
+
* identity still matches is signalled on its own; nothing unrecorded is ever signalled, only reported.
|
|
114
|
+
* A leader that is `reused` or `unverifiable` is never signalled at all, and its group is not scanned.
|
|
115
|
+
* `liveLeaderPid` is for a caller that still holds the unreaped child handle of the leader.
|
|
116
|
+
*/
|
|
117
|
+
export async function stopGroup(leader, members, opts) {
|
|
118
|
+
const sig = opts.signal ?? 'SIGTERM';
|
|
119
|
+
// A caller that still holds the unreaped child handle has already proved the leader pid is its own.
|
|
120
|
+
const check = opts.liveLeaderPid ? 'same' : checkIdentity(leader);
|
|
121
|
+
const result = { via: 'none', leader: check, graceful: true, stopped: [], survivors: [], unverified: [] };
|
|
122
|
+
if (check === 'same') {
|
|
123
|
+
const pgid = opts.liveLeaderPid ?? leader.pid;
|
|
124
|
+
const signal = (s) => { try {
|
|
125
|
+
process.kill(-pgid, s);
|
|
126
|
+
}
|
|
127
|
+
catch { /* group already gone */ } };
|
|
128
|
+
const groupAlive = () => { try {
|
|
129
|
+
process.kill(-pgid, 0);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return err.code === 'EPERM';
|
|
134
|
+
} };
|
|
135
|
+
signal(sig);
|
|
136
|
+
const deadline = Date.now() + opts.graceMs;
|
|
137
|
+
while (groupAlive() && Date.now() < deadline)
|
|
138
|
+
await sleep(50);
|
|
139
|
+
result.via = 'group';
|
|
140
|
+
result.graceful = !groupAlive();
|
|
141
|
+
if (!result.graceful) {
|
|
142
|
+
signal('SIGKILL');
|
|
143
|
+
for (let i = 0; i < 40 && groupAlive(); i++)
|
|
144
|
+
await sleep(50);
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
if (check === 'unverifiable')
|
|
149
|
+
return result;
|
|
150
|
+
// The leader is gone (or its pid now belongs to something else): only recorded, still-matching members.
|
|
151
|
+
const targets = members.filter((m) => checkIdentity(m) === 'same');
|
|
152
|
+
const as = (m) => ({ pid: m.pid, command: m.command });
|
|
153
|
+
const send = (list, s) => {
|
|
154
|
+
for (const m of list) {
|
|
155
|
+
// Re-check just before signalling: the pid must still be the recorded process.
|
|
156
|
+
if (checkIdentity(m) === 'same') {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(m.pid, s);
|
|
159
|
+
}
|
|
160
|
+
catch { /* gone */ }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
if (targets.length) {
|
|
165
|
+
result.via = 'members';
|
|
166
|
+
send(targets, sig);
|
|
167
|
+
const deadline = Date.now() + opts.graceMs;
|
|
168
|
+
while (targets.some(live) && Date.now() < deadline)
|
|
169
|
+
await sleep(50);
|
|
170
|
+
const stillRunning = targets.filter(live);
|
|
171
|
+
result.graceful = stillRunning.length === 0;
|
|
172
|
+
if (stillRunning.length) {
|
|
173
|
+
send(stillRunning, 'SIGKILL');
|
|
174
|
+
for (let i = 0; i < 40 && targets.some(live); i++)
|
|
175
|
+
await sleep(50);
|
|
176
|
+
}
|
|
177
|
+
result.stopped = targets.map(as);
|
|
178
|
+
result.survivors = targets.filter(live).map(as);
|
|
179
|
+
}
|
|
180
|
+
// A gone leader's pid cannot be reused while its group still has members, so what is left is that group.
|
|
181
|
+
if (check === 'gone' && leader) {
|
|
182
|
+
const known = new Set(members.map(keyOf));
|
|
183
|
+
result.unverified = listGroup(leader.pid).filter((p) => {
|
|
184
|
+
const id = identify(p.pid);
|
|
185
|
+
return !id || !known.has(keyOf(id));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
/** Notes for a person about a stop that went through members (or left unrecorded processes behind). */
|
|
191
|
+
export function describeMemberStop(r, graceMs) {
|
|
192
|
+
const parts = [];
|
|
193
|
+
if (r.stopped.length) {
|
|
194
|
+
const survivors = new Set(r.survivors.map((p) => p.pid));
|
|
195
|
+
const gone = r.stopped.filter((p) => !survivors.has(p.pid));
|
|
196
|
+
if (gone.length)
|
|
197
|
+
parts.push(`stopped ${gone.length === 1 ? 'recorded member' : 'recorded members'} ${gone.map(describeProcess).join(', ')}${r.graceful ? '' : ` (SIGKILL after ${graceMs}ms)`}`);
|
|
198
|
+
if (r.survivors.length)
|
|
199
|
+
parts.push(`recorded ${r.survivors.length === 1 ? 'member' : 'members'} still running after SIGKILL: ${r.survivors.map(describeProcess).join(', ')}`);
|
|
200
|
+
}
|
|
201
|
+
if (r.unverified.length) {
|
|
202
|
+
parts.push(`${r.unverified.length} ${r.unverified.length === 1 ? 'process remains' : 'processes remain'} in the group unverified and ${r.unverified.length === 1 ? 'was' : 'were'} not signalled: ${r.unverified.map(describeProcess).join(', ')} (stop ${r.unverified.length === 1 ? 'it' : 'them'} yourself if ${r.unverified.length === 1 ? 'it is' : 'they are'} part of the service)`);
|
|
203
|
+
}
|
|
204
|
+
return parts.join('; ');
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=process-group.js.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
export function identify(pid) {
|
|
4
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0)
|
|
5
|
+
return undefined;
|
|
6
|
+
if (process.platform === 'linux') {
|
|
7
|
+
try {
|
|
8
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
9
|
+
// Field 2 (comm) may contain spaces and parentheses; fields after the last ')' start at field 3.
|
|
10
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
11
|
+
const startTime = fields[19]; // field 22
|
|
12
|
+
if (!startTime)
|
|
13
|
+
return undefined;
|
|
14
|
+
return { pid, startTime, bootId: bootId() };
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const startTime = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
22
|
+
return startTime ? { pid, startTime } : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Compare a recorded identity with whatever currently holds that PID. */
|
|
29
|
+
export function checkIdentity(recorded) {
|
|
30
|
+
if (!recorded?.pid || !recorded.startTime)
|
|
31
|
+
return 'unverifiable';
|
|
32
|
+
const current = identify(recorded.pid);
|
|
33
|
+
if (!current)
|
|
34
|
+
return 'gone';
|
|
35
|
+
if (current.startTime !== recorded.startTime)
|
|
36
|
+
return 'reused';
|
|
37
|
+
if (recorded.bootId && current.bootId && recorded.bootId !== current.bootId)
|
|
38
|
+
return 'reused';
|
|
39
|
+
return 'same';
|
|
40
|
+
}
|
|
41
|
+
export function isSameProcess(recorded) {
|
|
42
|
+
return checkIdentity(recorded) === 'same';
|
|
43
|
+
}
|
|
44
|
+
let cachedBootId;
|
|
45
|
+
function bootId() {
|
|
46
|
+
if (cachedBootId === undefined) {
|
|
47
|
+
try {
|
|
48
|
+
cachedBootId = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
cachedBootId = '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return cachedBootId || undefined;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=process-identity.js.map
|