@modelprofile.com/browser-runtime 5.7.2 → 6.0.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.
@@ -6,6 +6,8 @@ const metadataSchema = 1;
6
6
  const maximumMetadataBytes = 4096;
7
7
  const maximumProcFileBytes = 2 * 1024 * 1024;
8
8
  const anchorInitializationAttempts = 200;
9
+ const emptyCommandAttempts = 5;
10
+ const emptyCommandRetryDelayMs = 50;
9
11
  const generationIdPattern = /^[A-Za-z0-9_-]{32}$/u;
10
12
  const hashPattern = /^[a-f0-9]{64}$/u;
11
13
  const bootIdPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
@@ -59,7 +61,26 @@ export interface IBrowserRuntimeGeneration {
59
61
  }
60
62
 
61
63
  type TInspectionResult = 'clear' | 'held' | 'indeterminate';
62
- type TProcessUidState = 'same' | 'other' | 'missing' | 'indeterminate';
64
+ /**
65
+ * `same` is every one of the four `/proc/<pid>/status` `Uid:` identifiers, `other` is none of
66
+ * them, and `shared` is a setuid credential transition that names this uid in some but not all
67
+ * of them (`sudo`, `su`, `passwd`, a daemon that assumed this identity). A `shared` process can
68
+ * still open this uid's profile, so it is a scan candidate; it is never a whole-scan fence.
69
+ */
70
+ type TProcessUidState = 'same' | 'other' | 'shared' | 'missing' | 'indeterminate';
71
+ type TEmptyCommandResolution =
72
+ | { kind: 'clear' }
73
+ | { kind: 'indeterminate' }
74
+ | { kind: 'command'; cmdline: plugins.Buffer };
75
+
76
+ /**
77
+ * Waits for one bounded retry step. The timer is referenced for its whole lifetime and always
78
+ * fires, so it is released as soon as the step ends and never outlives the acquisition it
79
+ * belongs to. An unreferenced timer would let the process exit in the middle of an inspection.
80
+ */
81
+ const delayStep = (millisecondsArg: number): Promise<void> => new Promise<void>((resolve) => {
82
+ setTimeout(resolve, millisecondsArg);
83
+ });
63
84
 
64
85
  const isMissingError = (errorArg: unknown): boolean => (
65
86
  (errorArg as NodeJS.ErrnoException).code === 'ENOENT'
@@ -1085,9 +1106,13 @@ export class BrowserRuntimeOwnership {
1085
1106
  }
1086
1107
 
1087
1108
  private async inspectProfileProcesses(profileRootsArg: string[]): Promise<TInspectionResult> {
1088
- const roots = profileRootsArg.map((root) => plugins.path.resolve(root));
1109
+ const roots = await this.presentProfileRoots(profileRootsArg);
1110
+ // The inspection exists only to protect a profile root that is about to be removed. When no
1111
+ // root is present there is nothing to protect and no containment match is possible, so the
1112
+ // process table is never enumerated.
1113
+ if (roots.length === 0) return 'clear';
1089
1114
  let indeterminate = false;
1090
- for (const pid of await this.listOwnedProcessIds()) {
1115
+ for (const pid of await this.listCandidateProcessIds()) {
1091
1116
  let cmdline: plugins.Buffer;
1092
1117
  try {
1093
1118
  cmdline = await this.readBoundedBuffer(
@@ -1095,6 +1120,8 @@ export class BrowserRuntimeOwnership {
1095
1120
  maximumProcFileBytes,
1096
1121
  );
1097
1122
  } catch {
1123
+ // A candidate whose command line cannot be read stays fail-closed: its identity is known
1124
+ // to involve this uid and nothing proves it is not using one of these profile roots.
1098
1125
  const uidState = await this.processStillExists(pid);
1099
1126
  if (uidState === 'missing' || uidState === 'other') continue;
1100
1127
  indeterminate = true;
@@ -1102,37 +1129,66 @@ export class BrowserRuntimeOwnership {
1102
1129
  }
1103
1130
  if (cmdline.byteLength === 0) {
1104
1131
  await this.afterEmptyProcessCmdline?.(pid);
1105
- if (!await this.emptyCommandProcessIsClear(pid)) indeterminate = true;
1106
- continue;
1107
- }
1108
- let cmdlineText: string;
1109
- try {
1110
- cmdlineText = new TextDecoder('utf-8', { fatal: true }).decode(cmdline);
1111
- } catch {
1112
- indeterminate = true;
1113
- continue;
1114
- }
1115
- const argumentsList = cmdlineText.split('\0');
1116
- if (argumentsList.at(-1) === '') argumentsList.pop();
1117
- for (let index = 0; index < argumentsList.length; index += 1) {
1118
- const argument = argumentsList[index]!;
1119
- let profilePath: string | undefined;
1120
- if (argument === '--user-data-dir') {
1121
- profilePath = argumentsList[index + 1];
1122
- } else if (argument.startsWith('--user-data-dir=')) {
1123
- profilePath = argument.slice('--user-data-dir='.length);
1124
- }
1125
- if (!profilePath || !plugins.path.isAbsolute(profilePath)) continue;
1126
- const resolved = plugins.path.resolve(profilePath);
1127
- if (roots.some((root) => resolved === root || this.pathIsWithin(root, resolved))) {
1128
- return 'held';
1132
+ const resolution = await this.resolveEmptyCommandProcess(pid);
1133
+ if (resolution.kind === 'clear') continue;
1134
+ if (resolution.kind === 'indeterminate') {
1135
+ indeterminate = true;
1136
+ continue;
1129
1137
  }
1138
+ cmdline = resolution.cmdline;
1130
1139
  }
1140
+ const containment = this.commandLineContainment(cmdline, roots);
1141
+ if (containment === 'held') return 'held';
1142
+ if (containment === 'indeterminate') indeterminate = true;
1131
1143
  }
1132
1144
  return indeterminate ? 'indeterminate' : 'clear';
1133
1145
  }
1134
1146
 
1135
- private async listOwnedProcessIds(): Promise<number[]> {
1147
+ private commandLineContainment(
1148
+ cmdlineArg: plugins.Buffer,
1149
+ rootsArg: string[],
1150
+ ): TInspectionResult {
1151
+ let cmdlineText: string;
1152
+ try {
1153
+ cmdlineText = new TextDecoder('utf-8', { fatal: true }).decode(cmdlineArg);
1154
+ } catch {
1155
+ return 'indeterminate';
1156
+ }
1157
+ const argumentsList = cmdlineText.split('\0');
1158
+ if (argumentsList.at(-1) === '') argumentsList.pop();
1159
+ for (let index = 0; index < argumentsList.length; index += 1) {
1160
+ const argument = argumentsList[index]!;
1161
+ let profilePath: string | undefined;
1162
+ if (argument === '--user-data-dir') {
1163
+ profilePath = argumentsList[index + 1];
1164
+ } else if (argument.startsWith('--user-data-dir=')) {
1165
+ profilePath = argument.slice('--user-data-dir='.length);
1166
+ }
1167
+ if (!profilePath || !plugins.path.isAbsolute(profilePath)) continue;
1168
+ const resolved = plugins.path.resolve(profilePath);
1169
+ if (rootsArg.some((root) => resolved === root || this.pathIsWithin(root, resolved))) {
1170
+ return 'held';
1171
+ }
1172
+ }
1173
+ return 'clear';
1174
+ }
1175
+
1176
+ private async presentProfileRoots(profileRootsArg: string[]): Promise<string[]> {
1177
+ const roots: string[] = [];
1178
+ for (const profileRoot of profileRootsArg) {
1179
+ const root = plugins.path.resolve(profileRoot);
1180
+ try {
1181
+ await plugins.fsPromises.lstat(root, { bigint: true });
1182
+ } catch (error) {
1183
+ if (isMissingError(error)) continue;
1184
+ throw new BrowserRuntimeError('FENCED', 'runtime profile root is unreadable');
1185
+ }
1186
+ roots.push(root);
1187
+ }
1188
+ return roots;
1189
+ }
1190
+
1191
+ private async listCandidateProcessIds(): Promise<number[]> {
1136
1192
  let entries: string[];
1137
1193
  try {
1138
1194
  // /proc can report DT_UNKNOWN. Node's eager Dirent conversion then
@@ -1161,8 +1217,11 @@ export class BrowserRuntimeOwnership {
1161
1217
  indeterminate = true;
1162
1218
  continue;
1163
1219
  }
1220
+ // A credential state alone never fences the scan. An unrelated process is discarded here
1221
+ // only when it definitely belongs to another uid; every process this uid owns or shares is
1222
+ // a candidate whose command line decides it in inspectProfileProcesses().
1164
1223
  const uidState = await this.processStillExists(pid);
1165
- if (uidState === 'same') processIds.push(pid);
1224
+ if (uidState === 'same' || uidState === 'shared') processIds.push(pid);
1166
1225
  else if (uidState === 'indeterminate') indeterminate = true;
1167
1226
  }
1168
1227
  if (indeterminate) {
@@ -1208,31 +1267,63 @@ export class BrowserRuntimeOwnership {
1208
1267
  }
1209
1268
  if (ids.every((id) => id === this.uid)) return 'same';
1210
1269
  if (ids.every((id) => id !== this.uid)) return 'other';
1211
- return 'indeterminate';
1270
+ return 'shared';
1212
1271
  }
1213
1272
 
1214
- private async emptyCommandProcessIsClear(pidArg: number): Promise<boolean> {
1273
+ /**
1274
+ * An empty `/proc/<pid>/cmdline` is not a decision. A candidate publishes one for a bounded
1275
+ * window while it is mid-`execve` or has already released its mm on the way to becoming a
1276
+ * zombie, and during that window `State:` still reports a running process. Re-read the
1277
+ * identity and the command line across that window before deciding anything.
1278
+ */
1279
+ private async resolveEmptyCommandProcess(pidArg: number): Promise<TEmptyCommandResolution> {
1215
1280
  const processPath = plugins.path.join(this.procRoot, String(pidArg));
1216
- try {
1217
- const status = await this.readBoundedFile(
1218
- plugins.path.join(processPath, 'status'),
1219
- 64 * 1024,
1220
- );
1281
+ for (let attempt = 0; attempt < emptyCommandAttempts; attempt += 1) {
1282
+ let status: string;
1283
+ try {
1284
+ status = await this.readBoundedFile(
1285
+ plugins.path.join(processPath, 'status'),
1286
+ 64 * 1024,
1287
+ );
1288
+ } catch (error) {
1289
+ return await this.departedProcessResolution(processPath, error);
1290
+ }
1221
1291
  const uidState = this.parseProcessUidState(status);
1222
- if (uidState === 'other') return true;
1223
- if (uidState !== 'same') return false;
1292
+ if (uidState === 'other') return { kind: 'clear' };
1293
+ if (uidState !== 'same' && uidState !== 'shared') return { kind: 'indeterminate' };
1224
1294
  const stateLines = status.split('\n').filter((line) => line.startsWith('State:'));
1225
- return stateLines.length === 1 && /^State:[\t ]+Z(?:[\t ]|$)/u.test(stateLines[0]!);
1226
- } catch (error) {
1227
- if (!isMissingError(error)) return false;
1228
- // An empty command line can precede process exit. A missing status file
1229
- // alone is insufficient: the PID directory must also have disappeared.
1295
+ if (stateLines.length !== 1) return { kind: 'indeterminate' };
1296
+ // A zombie has released its mm, holds no descriptor and cannot be using any profile root.
1297
+ if (/^State:[\t ]+Z(?:[\t ]|$)/u.test(stateLines[0]!)) return { kind: 'clear' };
1298
+ let cmdline: plugins.Buffer;
1230
1299
  try {
1231
- await plugins.fsPromises.lstat(processPath, { bigint: true });
1232
- return false;
1233
- } catch (statError) {
1234
- return isMissingError(statError);
1300
+ cmdline = await this.readBoundedBuffer(
1301
+ plugins.path.join(processPath, 'cmdline'),
1302
+ maximumProcFileBytes,
1303
+ );
1304
+ } catch (error) {
1305
+ return await this.departedProcessResolution(processPath, error);
1235
1306
  }
1307
+ if (cmdline.byteLength > 0) return { kind: 'command', cmdline };
1308
+ if (attempt + 1 < emptyCommandAttempts) await delayStep(emptyCommandRetryDelayMs);
1309
+ }
1310
+ // Still running and still command-less after the whole window. Nothing proves this process is
1311
+ // not using one of the inspected roots, so it stays fail-closed.
1312
+ return { kind: 'indeterminate' };
1313
+ }
1314
+
1315
+ private async departedProcessResolution(
1316
+ processPathArg: string,
1317
+ errorArg: unknown,
1318
+ ): Promise<TEmptyCommandResolution> {
1319
+ if (!isMissingError(errorArg)) return { kind: 'indeterminate' };
1320
+ // A missing status or command-line file alone is insufficient: the PID directory must also
1321
+ // have disappeared before the process counts as departed.
1322
+ try {
1323
+ await plugins.fsPromises.lstat(processPathArg, { bigint: true });
1324
+ return { kind: 'indeterminate' };
1325
+ } catch (statError) {
1326
+ return isMissingError(statError) ? { kind: 'clear' } : { kind: 'indeterminate' };
1236
1327
  }
1237
1328
  }
1238
1329
 
package/ts/interfaces.ts CHANGED
@@ -31,7 +31,11 @@ export interface IBrowserResourceKey {
31
31
  export interface IBrowserAttachmentBinding {
32
32
  attachmentAuthorityId: string;
33
33
  attachmentRevision: number;
34
- sessionId: TQualifiedBrowserSessionId | null;
34
+ /**
35
+ * The set of sessions attached to the resource. Order is not significant and duplicates are
36
+ * rejected. An empty set is detached, and revision `0` requires an empty set.
37
+ */
38
+ sessionIds: TQualifiedBrowserSessionId[];
35
39
  }
36
40
 
37
41
  export interface ICreateBrowserResourceRequest {
package/ts/plugins.ts CHANGED
@@ -16,12 +16,12 @@ export { Buffer, crypto, dns, fs, fsPromises, http, net, os, path, stream, tls,
16
16
 
17
17
  // foss.global scopes
18
18
  import * as flexharness from '@modelprofile.com/flexharness';
19
- import * as smartagent from '@push.rocks/smartagent';
19
+ import * as flexharnessTools from '@modelprofile.com/flexharness/tools';
20
20
  import * as smartipc from '@push.rocks/smartipc';
21
21
  import * as smartmcp from '@push.rocks/smartmcp';
22
22
  import * as smartpuppeteer from '@push.rocks/smartpuppeteer';
23
23
 
24
- export { flexharness, smartagent, smartipc, smartmcp, smartpuppeteer };
24
+ export { flexharness, flexharnessTools, smartipc, smartmcp, smartpuppeteer };
25
25
 
26
26
  // third-party scope
27
27
  import ipaddr from 'ipaddr.js';
package/.smartconfig.json DELETED
@@ -1,34 +0,0 @@
1
- {
2
- "@git.zone/cli": {
3
- "schemaVersion": 2,
4
- "projectType": "npm",
5
- "module": {
6
- "githost": "code.foss.global",
7
- "gitscope": "modelprofile.com",
8
- "gitrepo": "browser-runtime",
9
- "description": "Parent-owned, resource-centric Chromium runtime with revisioned attachment fencing, authenticated human and agent control, fail-closed egress, bounded artifacts, and Flex/MCP adapters.",
10
- "npmPackagename": "@modelprofile.com/browser-runtime",
11
- "license": "MIT",
12
- "projectDomain": "modelprofile.com"
13
- },
14
- "release": {
15
- "targets": {
16
- "git": {
17
- "enabled": true,
18
- "remote": "origin",
19
- "pushBranch": true,
20
- "pushTags": true
21
- },
22
- "npm": {
23
- "enabled": true,
24
- "registries": [
25
- "https://verdaccio.lossless.digital",
26
- "https://registry.npmjs.org"
27
- ],
28
- "accessLevel": "public",
29
- "alreadyPublished": "success"
30
- }
31
- }
32
- }
33
- }
34
- }