@modelprofile.com/browser-runtime 3.2.0 → 4.1.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.
@@ -1,5 +1,6 @@
1
1
  import type * as plugins from './plugins.js';
2
2
  import type { IBrowserConfinementProbeContext, IBrowserRuntimeFramedClientOptions, IBrowserRuntimeOptions, IBrowserRuntimeEnvironment, ILiveBrowserSessionLike } from './interfaces.js';
3
+ import type { IBrowserRuntimeOwnershipTestingOptions } from './classes.runtimeownership.js';
3
4
  export declare const browserRuntimeTesting: unique symbol;
4
5
  export interface IBrowserRuntimeTestingOptions extends IBrowserRuntimeOptions {
5
6
  [browserRuntimeTesting]: true;
@@ -11,6 +12,7 @@ export interface IBrowserRuntimeTestingOptions extends IBrowserRuntimeOptions {
11
12
  beforeFrameFailureTermination?(): Promise<void>;
12
13
  afterFrameFailureFence?(): Promise<void>;
13
14
  beforeFramedPeerLeasePublication?(): Promise<void>;
15
+ ownership?: IBrowserRuntimeOwnershipTestingOptions;
14
16
  }
15
17
  export interface IBrowserRuntimeFramedClientTestingOptions extends IBrowserRuntimeFramedClientOptions {
16
18
  [browserRuntimeTesting]: true;
@@ -1,2 +1,2 @@
1
1
  export const browserRuntimeTesting = Symbol('browserRuntimeTesting');
2
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJuYWwudGVzdGluZy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL2ludGVybmFsLnRlc3RpbmcudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBU0EsTUFBTSxDQUFDLE1BQU0scUJBQXFCLEdBQUcsTUFBTSxDQUFDLHVCQUF1QixDQUFDLENBQUMifQ==
2
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJuYWwudGVzdGluZy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL2ludGVybmFsLnRlc3RpbmcudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBVUEsTUFBTSxDQUFDLE1BQU0scUJBQXFCLEdBQUcsTUFBTSxDQUFDLHVCQUF1QixDQUFDLENBQUMifQ==
@@ -13,8 +13,9 @@ import * as url from 'node:url';
13
13
  export { Buffer, crypto, dns, fs, fsPromises, http, net, os, path, stream, tls, url };
14
14
  import * as flexharness from '@modelprofile.com/flexharness';
15
15
  import * as smartagent from '@push.rocks/smartagent';
16
+ import * as smartipc from '@push.rocks/smartipc';
16
17
  import * as smartmcp from '@push.rocks/smartmcp';
17
18
  import * as smartpuppeteer from '@push.rocks/smartpuppeteer';
18
- export { flexharness, smartagent, smartmcp, smartpuppeteer };
19
+ export { flexharness, smartagent, smartipc, smartmcp, smartpuppeteer };
19
20
  import ipaddr from 'ipaddr.js';
20
21
  export { ipaddr };
@@ -15,10 +15,11 @@ export { Buffer, crypto, dns, fs, fsPromises, http, net, os, path, stream, tls,
15
15
  // foss.global scopes
16
16
  import * as flexharness from '@modelprofile.com/flexharness';
17
17
  import * as smartagent from '@push.rocks/smartagent';
18
+ import * as smartipc from '@push.rocks/smartipc';
18
19
  import * as smartmcp from '@push.rocks/smartmcp';
19
20
  import * as smartpuppeteer from '@push.rocks/smartpuppeteer';
20
- export { flexharness, smartagent, smartmcp, smartpuppeteer };
21
+ export { flexharness, smartagent, smartipc, smartmcp, smartpuppeteer };
21
22
  // third-party scope
22
23
  import ipaddr from 'ipaddr.js';
23
24
  export { ipaddr };
24
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsb0JBQW9CO0FBQ3BCLE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFDckMsT0FBTyxLQUFLLE1BQU0sTUFBTSxhQUFhLENBQUM7QUFDdEMsT0FBTyxLQUFLLEdBQUcsTUFBTSxVQUFVLENBQUM7QUFDaEMsT0FBTyxLQUFLLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDOUIsT0FBTyxLQUFLLFVBQVUsTUFBTSxrQkFBa0IsQ0FBQztBQUMvQyxPQUFPLEtBQUssSUFBSSxNQUFNLFdBQVcsQ0FBQztBQUNsQyxPQUFPLEtBQUssR0FBRyxNQUFNLFVBQVUsQ0FBQztBQUNoQyxPQUFPLEtBQUssRUFBRSxNQUFNLFNBQVMsQ0FBQztBQUM5QixPQUFPLEtBQUssSUFBSSxNQUFNLFdBQVcsQ0FBQztBQUNsQyxPQUFPLEtBQUssTUFBTSxNQUFNLGFBQWEsQ0FBQztBQUN0QyxPQUFPLEtBQUssR0FBRyxNQUFNLFVBQVUsQ0FBQztBQUNoQyxPQUFPLEtBQUssR0FBRyxNQUFNLFVBQVUsQ0FBQztBQUVoQyxPQUFPLEVBQUUsTUFBTSxFQUFFLE1BQU0sRUFBRSxHQUFHLEVBQUUsRUFBRSxFQUFFLFVBQVUsRUFBRSxJQUFJLEVBQUUsR0FBRyxFQUFFLEVBQUUsRUFBRSxJQUFJLEVBQUUsTUFBTSxFQUFFLEdBQUcsRUFBRSxHQUFHLEVBQUUsQ0FBQztBQUV0RixxQkFBcUI7QUFDckIsT0FBTyxLQUFLLFdBQVcsTUFBTSwrQkFBK0IsQ0FBQztBQUM3RCxPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBQ3JELE9BQU8sS0FBSyxRQUFRLE1BQU0sc0JBQXNCLENBQUM7QUFDakQsT0FBTyxLQUFLLGNBQWMsTUFBTSw0QkFBNEIsQ0FBQztBQUU3RCxPQUFPLEVBQUUsV0FBVyxFQUFFLFVBQVUsRUFBRSxRQUFRLEVBQUUsY0FBYyxFQUFFLENBQUM7QUFFN0Qsb0JBQW9CO0FBQ3BCLE9BQU8sTUFBTSxNQUFNLFdBQVcsQ0FBQztBQUUvQixPQUFPLEVBQUUsTUFBTSxFQUFFLENBQUMifQ==
25
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsb0JBQW9CO0FBQ3BCLE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFDckMsT0FBTyxLQUFLLE1BQU0sTUFBTSxhQUFhLENBQUM7QUFDdEMsT0FBTyxLQUFLLEdBQUcsTUFBTSxVQUFVLENBQUM7QUFDaEMsT0FBTyxLQUFLLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDOUIsT0FBTyxLQUFLLFVBQVUsTUFBTSxrQkFBa0IsQ0FBQztBQUMvQyxPQUFPLEtBQUssSUFBSSxNQUFNLFdBQVcsQ0FBQztBQUNsQyxPQUFPLEtBQUssR0FBRyxNQUFNLFVBQVUsQ0FBQztBQUNoQyxPQUFPLEtBQUssRUFBRSxNQUFNLFNBQVMsQ0FBQztBQUM5QixPQUFPLEtBQUssSUFBSSxNQUFNLFdBQVcsQ0FBQztBQUNsQyxPQUFPLEtBQUssTUFBTSxNQUFNLGFBQWEsQ0FBQztBQUN0QyxPQUFPLEtBQUssR0FBRyxNQUFNLFVBQVUsQ0FBQztBQUNoQyxPQUFPLEtBQUssR0FBRyxNQUFNLFVBQVUsQ0FBQztBQUVoQyxPQUFPLEVBQUUsTUFBTSxFQUFFLE1BQU0sRUFBRSxHQUFHLEVBQUUsRUFBRSxFQUFFLFVBQVUsRUFBRSxJQUFJLEVBQUUsR0FBRyxFQUFFLEVBQUUsRUFBRSxJQUFJLEVBQUUsTUFBTSxFQUFFLEdBQUcsRUFBRSxHQUFHLEVBQUUsQ0FBQztBQUV0RixxQkFBcUI7QUFDckIsT0FBTyxLQUFLLFdBQVcsTUFBTSwrQkFBK0IsQ0FBQztBQUM3RCxPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBQ3JELE9BQU8sS0FBSyxRQUFRLE1BQU0sc0JBQXNCLENBQUM7QUFDakQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssY0FBYyxNQUFNLDRCQUE0QixDQUFDO0FBRTdELE9BQU8sRUFBRSxXQUFXLEVBQUUsVUFBVSxFQUFFLFFBQVEsRUFBRSxRQUFRLEVBQUUsY0FBYyxFQUFFLENBQUM7QUFFdkUsb0JBQW9CO0FBQ3BCLE9BQU8sTUFBTSxNQUFNLFdBQVcsQ0FBQztBQUUvQixPQUFPLEVBQUUsTUFBTSxFQUFFLENBQUMifQ==
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelprofile.com/browser-runtime",
3
- "version": "3.2.0",
3
+ "version": "4.1.0",
4
4
  "private": false,
5
5
  "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.",
6
6
  "main": "dist_ts/index.js",
@@ -23,11 +23,12 @@
23
23
  "url": "https://community.foss.global/"
24
24
  },
25
25
  "engines": {
26
- "node": ">=24"
26
+ "node": ">=24 <27"
27
27
  },
28
28
  "dependencies": {
29
29
  "@modelprofile.com/flexharness": "^4.0.0",
30
30
  "@push.rocks/smartagent": "^4.8.0",
31
+ "@push.rocks/smartipc": "^2.5.0",
31
32
  "@push.rocks/smartmcp": "^0.3.0",
32
33
  "@push.rocks/smartpuppeteer": "^2.6.0",
33
34
  "ipaddr.js": "^2.5.0"
package/readme.hints.md CHANGED
@@ -4,6 +4,13 @@ Durable implementation findings for `@modelprofile.com/browser-runtime`.
4
4
 
5
5
  ## Ownership and lifecycle
6
6
 
7
+ - Host-runtime liveness is owned by SmartIPC `NamedMutex` using a namespace bound to the UID and secured runtime-directory device, inode, and path hash. Its permanent private `runtime.mutex` anchor directory and file stay on the runtime directory's trusted local filesystem. Clean native contention is `LOCKED`; backend, anchor, lock, or release uncertainty is `FENCED`.
8
+ - Persistent, fsynced `runtime.lock` metadata is a downgrade fence, not the live mutex, and is never removed by normal shutdown. Every start owns one metadata-named generation containing private profile and artifact roots.
9
+ - Metadata-generation recovery performs no lock-descriptor scan. It removes only the exact named generation after checking every same-UID process for an absolute `--user-data-dir` equal to or below that profile root; this check is process-generic, not Chromium-specific.
10
+ - Process ownership comes from all four `/proc/<pid>/status` `Uid:` values, not `/proc/<pid>` directory ownership. Only an exact same-UID or definite other-UID classification is actionable; mixed, unreadable, malformed, symlinked, wrongly owned/mode, hard-linked, ambiguous, or uncontained ownership state remains fenced.
11
+ - Legacy zero-byte locks alone use exact-inode descriptor inspection. Adoption additionally requires birth, change, and modification timestamps strictly before bounded `/proc/stat` `btime`, plus no same-UID process using the exact or descendant legacy profile root. Same-boot, malformed, or ambiguous state remains fenced against the 3.2 close-before-unlink race.
12
+ - A clean 3.2 stopped layout has no lock and may retain private empty top-level profile and artifact roots. The native-mutex winner validates, process-checks, and removes only those roots. Before metadata fsync succeeds, failure cleanup may unlink only the exact lock inode that acquisition just created; preexisting locks are never removed.
13
+ - Shutdown attempts artifact and ownership cleanup after peer, revocation, or resource failures, retains retryable local slot state, and blocks restart until that state clears. Automatic ownership-loss cleanup gets one bounded retry; stale active metadata remains a fail-closed recovery fence.
7
14
  - Controller durable truth is a logical resource with immutable `projectId`, stable random `browserResourceId`, and a revisioned attachment binding. Runtime state is process-local and must be re-registered after every restart.
8
15
  - A resource slot owns only incarnation and arbitration state: its transition mutex, arbitration, incarnation, and process-local authority generations, lease, bounded operation FIFO, bounded frame window, Chromium session, authenticated egress proxy, private profile, and idle termination timer.
9
16
  - `terminateResource()` and idle termination remove only the incarnation. They preserve registration, attachment, capabilities, and artifacts. `retireResource()` permanently fences, revokes, terminates, purges exact-resource artifacts, then unregisters only after all cleanup succeeds. Durable retirement remains Controller truth; Runtime tombstones are bounded and process-local.
package/readme.md CHANGED
@@ -12,11 +12,12 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
12
12
  pnpm add @modelprofile.com/browser-runtime
13
13
  ```
14
14
 
15
- The runtime requires Node.js 24 or newer and a non-root Linux host. Production browser sessions require a sandbox-capable Chromium installation.
15
+ The runtime requires Node.js 24 through 26 on a non-root Linux x64 host. Production browser sessions require a sandbox-capable Chromium installation.
16
+ The `NamedMutex` backend uses the statically linked Rust helper packaged with `@push.rocks/smartipc`; startup fails closed with `FENCED` when the helper is unavailable.
16
17
 
17
18
  ## Resource Model
18
19
 
19
- The Controller owns durable resource and attachment truth. `BrowserRuntime` owns process-local registrations, fences, capabilities, leases, browser incarnations, artifacts, and its runtime lock. Registration never launches Chromium.
20
+ The Controller owns durable resource and attachment truth. `BrowserRuntime` owns process-local registrations, fences, capabilities, leases, browser incarnations, artifacts, and its host-runtime ownership lease. Registration never launches Chromium.
20
21
 
21
22
  ```typescript
22
23
  import {
@@ -73,7 +74,25 @@ try {
73
74
 
74
75
  Projects and qualified sessions may each own many resources. Human/agent arbitration, mutexes, leases, operation queues, profiles, proxies, frame subscriptions, and idle timers are per resource.
75
76
 
76
- `registerResource()` is idempotent only for the same project/resource key and identical attachment. `listResources()` reports process-local registration and incarnation metadata. `terminateResource()` terminates only the current incarnation and preserves registration, attachment, and artifacts. `retireResource()` permanently fences the process-local registration, revokes and quiesces authority, terminates its incarnation, purges exact-resource artifacts, and unregisters only after cleanup succeeds. The Controller separately owns durable retirement truth and must not rehydrate retired resources. Runtime tombstones and registrations are ephemeral, bounded process state. `stop()` revokes all process-local capabilities, terminates every incarnation, removes the Runtime-owned artifact root and registrations, and releases the lock without deleting Controller durable truth. `cleanupTimeoutMs` defaults to 30,000 milliseconds and accepts values from 100 through 120,000. If that caller-visible deadline expires, `stop()` rejects with `TIMEOUT` while Runtime retains ownership of the cleanup still in flight; a later `stop()` rejoins it, and `start()` waits for it to finish before restarting.
77
+ `registerResource()` is idempotent only for the same project/resource key and identical attachment. `listResources()` reports process-local registration and incarnation metadata. `terminateResource()` terminates only the current incarnation and preserves registration, attachment, and artifacts. `retireResource()` permanently fences the process-local registration, revokes and quiesces authority, terminates its incarnation, purges exact-resource artifacts, and unregisters only after cleanup succeeds. The Controller separately owns durable retirement truth and must not rehydrate retired resources. Runtime tombstones and registrations are ephemeral, bounded process state. `stop()` attempts peer, capability, resource, artifact-store, and ownership cleanup even when an earlier stage fails, then reports all failures together. It removes only the current generation, durably marks its owner metadata relinquished, and releases the native ownership lease when ownership cleanup succeeds. It intentionally leaves `runtime.lock` and the mutex anchor in place. Failed local slot cleanup remains retryable and blocks a new generation; automatic ownership-loss shutdown performs one additional bounded cleanup attempt. `cleanupTimeoutMs` defaults to 30,000 milliseconds and accepts values from 100 through 120,000. If that caller-visible deadline expires, `stop()` rejects with `TIMEOUT` while Runtime retains cleanup still in flight; a later `stop()` rejoins it, and `start()` waits for all prior local cleanup before restarting.
78
+
79
+ ## Host Runtime Ownership
80
+
81
+ The runtime directory must be an owner-only `0700` directory on a trusted local filesystem; NFS and other network filesystems are unsupported. Its permanent `runtime.mutex` anchor directory is an owner-only `0700` directory on the same filesystem device. Runtime uses `@push.rocks/smartipc` `NamedMutex` with a namespace bound to the UID plus the validated runtime-directory device, inode, and path hash. SmartIPC keeps one permanent `0600`, single-link anchor file in `runtime.mutex` and holds a native advisory lock for the complete metadata and generation operation. Clean `tryAcquire()` contention returns `LOCKED`. Unsupported native backends, unsafe or changed anchors, native lock failures, and uncertain release return `FENCED`. Process exit, `SIGKILL`, and reboot release the advisory lock in the kernel.
82
+
83
+ `runtime.lock` is a permanent `0600`, single-link regular file retained through an open descriptor while ownership is live. It is metadata and a downgrade fence, not the live mutex, and is never removed by `stop()`. Version 3.2 and older create-exclusive runtimes therefore remain fenced during and between new-runtime generations. Bounded JSON metadata is overwritten and fsynced through that descriptor. It records the schema, directory identity, UID, boot ID, PID, `/proc/self/stat` start ticks, generation ID, nonce, and active/relinquished state. Malformed metadata or unsafe directory, anchor, lock, link, ownership, mode, containment, or inspection state fails closed with `FENCED`.
84
+
85
+ Each successful `start()` creates private `generations/<generationId>/profiles` and `generations/<generationId>/artifacts` directories. A replacement holding the native mutex may inspect and remove only the generation named by valid metadata. For metadata generations, Runtime does not scan `runtime.lock` descriptors. It inspects every same-UID process and returns `LOCKED` when any process, Chromium or otherwise, has an absolute `--user-data-dir` equal to or below that generation's profile root. Process ownership is classified from all four IDs in `/proc/<pid>/status` `Uid:`: exact same-UID processes are inspected, definite other-UID processes are ignored, and mixed or unreadable identity remains indeterminate. Unknown generations and indeterminate inspection remain fenced.
86
+
87
+ A version 3.2 zero-byte lock uses a separate one-time migration gate. Runtime adopts it only when an exact-inode legacy descriptor scan finds no holder, the lock's birth, change, and modification timestamps are all strictly before the bounded `/proc/stat` `btime`, and no same-UID process has `--user-data-dir` equal to or below the legacy `profiles` root. A same-boot unheld lock is `FENCED`; this prevents takeover during the old owner's close-before-unlink window. Malformed boot data or ambiguous timestamps are also `FENCED`.
88
+
89
+ When version 3.2 stopped cleanly and removed `runtime.lock`, it may leave empty top-level `profiles` and `artifacts` directories. After winning creation of a new lock under the native mutex, Runtime validates those directories as exact private empty roots, performs the same exact-or-descendant profile-process inspection, removes only the validated roots, and publishes the new metadata without applying the stale-lock boot gate. If startup fails before metadata is durably published, Runtime unlinks only that exact process-created lock inode while still holding the native mutex; a preexisting lock is never removed.
90
+
91
+ ## Breaking Changes
92
+
93
+ The version 3.2 runtime-directory migration is one-way. A cleanly stopped 3.2 runtime that removed its zero-byte lock can be upgraded directly when any remaining top-level `profiles` and `artifacts` directories are private and empty. A stale 3.2 lock is recoverable only after reboot, once it conclusively predates the current boot and no legacy holder or profile process remains. The first successful new-runtime start writes permanent metadata and creates `runtime.mutex`; version 3.2 must not reuse that directory.
94
+
95
+ To downgrade, first stop the new runtime and verify that no process still uses any generation profile path. Then remove the entire runtime directory, including `runtime.lock`, `runtime.mutex`, and `generations`, and let version 3.2 create a fresh directory. Never delete only the lock or anchor, and never perform this procedure while either runtime generation is active.
77
96
 
78
97
  ## Attachment Fencing
79
98
 
@@ -138,7 +157,7 @@ pnpm test
138
157
  pnpm run test:real-chrome
139
158
  ```
140
159
 
141
- The real Chromium check launches two resources in one project and qualified session, verifies distinct private profiles, sandboxed renderer process trees, mandatory confinement, screenshots, confirmed shutdown, and profile deletion.
160
+ The real Chromium check launches two resources in one project and qualified session, verifies distinct generation-scoped private profiles, sandboxed renderer process trees, mandatory confinement, screenshots, confirmed shutdown, and generation deletion.
142
161
 
143
162
  ## License and Legal Information
144
163
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/browser-runtime',
6
- version: '3.2.0',
6
+ version: '4.1.0',
7
7
  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.'
8
8
  }
@@ -32,7 +32,7 @@ interface IProjectArtifacts {
32
32
  }
33
33
 
34
34
  export class BrowserArtifactStore {
35
- private readonly rootDirectory: string;
35
+ private rootDirectory: string;
36
36
  private readonly maxFileBytes: number;
37
37
  private readonly maxArtifactsPerResource: number;
38
38
  private readonly maxResourceBytes: number;
@@ -127,6 +127,17 @@ export class BrowserArtifactStore {
127
127
  return this.startPromise;
128
128
  }
129
129
 
130
+ /** @internal */
131
+ public setRootDirectoryForRuntime(rootDirectoryArg: string): void {
132
+ if (
133
+ !plugins.path.isAbsolute(rootDirectoryArg)
134
+ || this.started
135
+ || this.startPromise
136
+ || this.stopPromise
137
+ ) throw new BrowserRuntimeError('FENCED', 'artifact store cannot change generations');
138
+ this.rootDirectory = rootDirectoryArg;
139
+ }
140
+
130
141
  public stop(): Promise<void> {
131
142
  if (this.stopPromise) return this.stopPromise;
132
143
  if (!this.started && !this.startPromise) return Promise.resolve();
@@ -384,15 +395,7 @@ export class BrowserArtifactStore {
384
395
  }
385
396
  throw error;
386
397
  }
387
- const stat = await plugins.fsPromises.lstat(this.rootDirectory);
388
- const uid = process.getuid?.();
389
- if (
390
- !stat.isDirectory()
391
- || stat.isSymbolicLink()
392
- || (uid !== undefined && stat.uid !== uid)
393
- || (stat.mode & 0o777) !== 0o700
394
- ) throw new BrowserRuntimeError('FENCED', 'artifact root is not private');
395
- await plugins.fsPromises.chmod(this.rootDirectory, 0o700);
398
+ await this.secureNewDirectory(this.rootDirectory);
396
399
  this.started = true;
397
400
  const sweepIntervalMs = Math.min(60_000, Math.max(1000, Math.floor(this.artifactTtlMs / 2)));
398
401
  this.sweepTimer = setInterval(() => {
@@ -411,10 +414,12 @@ export class BrowserArtifactStore {
411
414
 
412
415
  private async createProject(projectId: string): Promise<IProjectArtifacts> {
413
416
  const directoryName = this.digestDirectory(`project\0${projectId}`);
414
- await plugins.fsPromises.mkdir(plugins.path.join(this.rootDirectory, directoryName), {
417
+ const projectDirectory = plugins.path.join(this.rootDirectory, directoryName);
418
+ await plugins.fsPromises.mkdir(projectDirectory, {
415
419
  recursive: false,
416
420
  mode: 0o700,
417
421
  });
422
+ await this.secureNewDirectory(projectDirectory);
418
423
  const project: IProjectArtifacts = {
419
424
  directoryName,
420
425
  totalBytes: 0,
@@ -431,10 +436,13 @@ export class BrowserArtifactStore {
431
436
  browserResourceId: string,
432
437
  ): Promise<IResourceArtifacts> {
433
438
  const directoryName = this.digestDirectory(`resource\0${projectId}\0${browserResourceId}`);
434
- await plugins.fsPromises.mkdir(
435
- plugins.path.join(this.rootDirectory, project.directoryName, directoryName),
436
- { recursive: false, mode: 0o700 },
439
+ const resourceDirectory = plugins.path.join(
440
+ this.rootDirectory,
441
+ project.directoryName,
442
+ directoryName,
437
443
  );
444
+ await plugins.fsPromises.mkdir(resourceDirectory, { recursive: false, mode: 0o700 });
445
+ await this.secureNewDirectory(resourceDirectory);
438
446
  const resource: IResourceArtifacts = {
439
447
  browserResourceId,
440
448
  directoryName,
@@ -445,6 +453,35 @@ export class BrowserArtifactStore {
445
453
  return resource;
446
454
  }
447
455
 
456
+ private async secureNewDirectory(pathArg: string): Promise<void> {
457
+ await plugins.fsPromises.chmod(pathArg, 0o700);
458
+ const handle = await plugins.fsPromises.open(
459
+ pathArg,
460
+ plugins.fs.constants.O_RDONLY
461
+ | plugins.fs.constants.O_DIRECTORY
462
+ | plugins.fs.constants.O_NOFOLLOW,
463
+ );
464
+ try {
465
+ await handle.chmod(0o700);
466
+ await plugins.fsPromises.chmod(pathArg, 0o700);
467
+ const [pathStat, handleStat] = await Promise.all([
468
+ plugins.fsPromises.lstat(pathArg, { bigint: true }),
469
+ handle.stat({ bigint: true }),
470
+ ]);
471
+ const uid = process.getuid?.();
472
+ if (
473
+ !pathStat.isDirectory()
474
+ || pathStat.isSymbolicLink()
475
+ || (uid !== undefined && pathStat.uid !== BigInt(uid))
476
+ || (pathStat.mode & 0o777n) !== 0o700n
477
+ || pathStat.dev !== handleStat.dev
478
+ || pathStat.ino !== handleStat.ino
479
+ ) throw new BrowserRuntimeError('FENCED', 'artifact directory is not private');
480
+ } finally {
481
+ await handle.close();
482
+ }
483
+ }
484
+
448
485
  private async purgeExpiredInternal(resourceKey?: IBrowserResourceKey): Promise<number> {
449
486
  const projectEntries = resourceKey
450
487
  ? [[resourceKey.projectId, this.projects.get(resourceKey.projectId)] as const]
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
2
2
  import { validateAgentAction } from './actions.js';
3
3
  import { BrowserArtifactStore } from './classes.artifactstore.js';
4
4
  import { BrowserEgressProxy } from './classes.egressproxy.js';
5
+ import { BrowserRuntimeOwnership } from './classes.runtimeownership.js';
5
6
  import { runProductionConfinementProbe } from './confinement.js';
6
7
  import { BrowserRuntimeError } from './errors.js';
7
8
  import {
@@ -89,6 +90,7 @@ interface INormalizedRuntimeOptions {
89
90
  maxOutstandingFrames: number;
90
91
  frameAcknowledgementTimeoutMs: number;
91
92
  egress: NonNullable<IBrowserRuntimeOptions['egress']>;
93
+ ownership?: IBrowserRuntimeTestingOptions['ownership'];
92
94
  beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
93
95
  beforeIdleTermination?: IBrowserRuntimeTestingOptions['beforeIdleTermination'];
94
96
  beforeFrameFailureTermination?: IBrowserRuntimeTestingOptions['beforeFrameFailureTermination'];
@@ -233,9 +235,8 @@ const identifierMaximum = 256;
233
235
 
234
236
  export class BrowserRuntime {
235
237
  private readonly options: INormalizedRuntimeOptions;
236
- private readonly profileRoot: string;
237
- private readonly artifactRoot: string;
238
- private readonly lockPath: string;
238
+ private readonly ownership: BrowserRuntimeOwnership;
239
+ private profileRoot?: string;
239
240
  private readonly artifactStore: BrowserArtifactStore;
240
241
  private readonly runtimeAuthorityId = randomId(18);
241
242
  private readonly slots = new Map<string, IResourceSlot>();
@@ -247,11 +248,10 @@ export class BrowserRuntime {
247
248
  private readonly capabilityMutex = new TransitionMutex();
248
249
  private readonly cleanupOperations = new Set<Promise<void>>();
249
250
  private readonly framedPeers = new Set<BrowserRuntimeFramedServerPeer>();
250
- private lockHandle?: plugins.fsPromises.FileHandle;
251
- private lockHandleClosed = false;
252
251
  private startPromise?: Promise<void>;
253
252
  private stopPromise?: Promise<void>;
254
253
  private stopCleanupPromise?: Promise<void>;
254
+ private localCleanupPending = false;
255
255
  private lifecycleState: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
256
256
  private lifecycleEpoch = 0;
257
257
  private lifecycleController = new AbortController();
@@ -473,17 +473,21 @@ export class BrowserRuntime {
473
473
  10_000,
474
474
  ),
475
475
  egress: options.egress ?? {},
476
+ ownership: testingOptions?.ownership,
476
477
  beforeLeasePublication: testingOptions?.beforeLeasePublication,
477
478
  beforeIdleTermination: testingOptions?.beforeIdleTermination,
478
479
  beforeFrameFailureTermination: testingOptions?.beforeFrameFailureTermination,
479
480
  afterFrameFailureFence: testingOptions?.afterFrameFailureFence,
480
481
  beforeFramedPeerLeasePublication: testingOptions?.beforeFramedPeerLeasePublication,
481
482
  };
482
- this.profileRoot = plugins.path.join(options.runtimeDirectory, 'profiles');
483
- this.artifactRoot = plugins.path.join(options.runtimeDirectory, 'artifacts');
484
- this.lockPath = plugins.path.join(options.runtimeDirectory, 'runtime.lock');
483
+ this.ownership = new BrowserRuntimeOwnership({
484
+ runtimeDirectory: options.runtimeDirectory,
485
+ uid: environment.uid ?? -1,
486
+ testing: testingOptions?.ownership,
487
+ onOwnershipLost: () => this.handleOwnershipLost(),
488
+ });
485
489
  this.artifactStore = new BrowserArtifactStore({
486
- rootDirectory: this.artifactRoot,
490
+ rootDirectory: plugins.path.join(options.runtimeDirectory, 'artifacts'),
487
491
  ...(options.artifacts ?? {}),
488
492
  });
489
493
  }
@@ -492,6 +496,13 @@ export class BrowserRuntime {
492
496
  if (this.lifecycleState === 'running') return Promise.resolve();
493
497
  if (this.startPromise) return this.startPromise;
494
498
  if (this.stopCleanupPromise) return this.stopCleanupPromise.then(() => this.start());
499
+ if (
500
+ this.lifecycleState === 'stopping'
501
+ || this.localCleanupPending
502
+ || this.ownership.cleanupPending
503
+ ) {
504
+ return this.stop().then(() => this.start());
505
+ }
495
506
  this.lifecycleState = 'starting';
496
507
  const epoch = ++this.lifecycleEpoch;
497
508
  this.lifecycleController = new AbortController();
@@ -515,7 +526,8 @@ export class BrowserRuntime {
515
526
  if (this.stopPromise) return this.stopPromise;
516
527
  if (
517
528
  this.lifecycleState === 'stopped'
518
- && !this.lockHandle
529
+ && !this.localCleanupPending
530
+ && !this.ownership.cleanupPending
519
531
  && !this.startPromise
520
532
  && !this.stopCleanupPromise
521
533
  ) {
@@ -528,8 +540,13 @@ export class BrowserRuntime {
528
540
  const startup = this.startPromise;
529
541
  const cleanup = (async () => {
530
542
  await startup?.catch(() => undefined);
531
- await this.stopInternal();
532
- this.lifecycleState = 'stopped';
543
+ try {
544
+ await this.stopInternal();
545
+ } finally {
546
+ if (!this.localCleanupPending && !this.ownership.cleanupPending) {
547
+ this.lifecycleState = 'stopped';
548
+ }
549
+ }
533
550
  })();
534
551
  this.stopCleanupPromise = cleanup;
535
552
  void cleanup.then(
@@ -1321,7 +1338,7 @@ export class BrowserRuntime {
1321
1338
  ): Promise<Uint8Array> {
1322
1339
  this.requireHuman(lease);
1323
1340
  this.requireValidLease(lease);
1324
- return this.artifactStore.read(
1341
+ return this.requireArtifactStore().read(
1325
1342
  lease.capability.projectId,
1326
1343
  lease.capability.browserResourceId,
1327
1344
  artifactId,
@@ -1332,7 +1349,7 @@ export class BrowserRuntime {
1332
1349
  public async deleteLeaseArtifact(lease: ILeaseRecord, artifactId: string): Promise<void> {
1333
1350
  this.requireHuman(lease);
1334
1351
  this.requireValidLease(lease);
1335
- await this.artifactStore.delete(
1352
+ await this.requireArtifactStore().delete(
1336
1353
  lease.capability.projectId,
1337
1354
  lease.capability.browserResourceId,
1338
1355
  artifactId,
@@ -1360,30 +1377,31 @@ export class BrowserRuntime {
1360
1377
  if (this.options.environment.uid === undefined || this.options.environment.uid === 0) {
1361
1378
  throw new BrowserRuntimeError('FENCED', 'browser runtime requires a non-root uid');
1362
1379
  }
1363
- await plugins.fsPromises.mkdir(this.options.runtimeDirectory, { recursive: true, mode: 0o700 });
1364
- await plugins.fsPromises.chmod(this.options.runtimeDirectory, 0o700);
1365
- if (!this.lockHandle) {
1380
+ const generation = await this.ownership.acquire();
1381
+ this.profileRoot = generation.profileRoot;
1382
+ try {
1383
+ this.artifactStore.setRootDirectoryForRuntime(generation.artifactRoot);
1384
+ await plugins.fsPromises.mkdir(this.profileRoot, { recursive: false, mode: 0o700 });
1385
+ await this.secureNewPrivateDirectory(this.profileRoot);
1386
+ await this.artifactStore.start();
1387
+ } catch (error) {
1366
1388
  try {
1367
- this.lockHandle = await plugins.fsPromises.open(this.lockPath, 'wx', 0o600);
1368
- this.lockHandleClosed = false;
1369
- await this.lockHandle.chmod(0o600);
1370
- } catch (error) {
1371
- if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
1372
- throw new BrowserRuntimeError('LOCKED');
1373
- }
1374
- throw error;
1389
+ await this.ownership.release('startup-failure');
1390
+ this.profileRoot = undefined;
1391
+ } catch {
1392
+ throw new BrowserRuntimeError('FENCED', 'runtime startup cleanup is incomplete');
1375
1393
  }
1394
+ throw error;
1376
1395
  }
1377
- await plugins.fsPromises.mkdir(this.profileRoot, { recursive: true, mode: 0o700 });
1378
- await plugins.fsPromises.chmod(this.profileRoot, 0o700);
1379
- const profileEntries = await plugins.fsPromises.readdir(this.profileRoot);
1380
- if (profileEntries.length > 0) {
1381
- throw new BrowserRuntimeError('FENCED', 'profile root is not empty');
1382
- }
1383
- await this.artifactStore.start();
1384
1396
  }
1385
1397
 
1386
1398
  private async stopInternal(): Promise<void> {
1399
+ this.localCleanupPending = true;
1400
+ const errors: unknown[] = [];
1401
+ const localErrors: unknown[] = [];
1402
+ if (this.ownership.owned) {
1403
+ await this.ownership.assertCurrentOwnership().catch((error) => errors.push(error));
1404
+ }
1387
1405
  const peerResults = await Promise.allSettled(
1388
1406
  [...this.framedPeers].map((peer) => peer.close()),
1389
1407
  );
@@ -1402,34 +1420,98 @@ export class BrowserRuntime {
1402
1420
  while (this.cleanupOperations.size > 0) {
1403
1421
  await Promise.allSettled([...this.cleanupOperations]);
1404
1422
  }
1405
- const errors = [...peerResults, ...revokeResults, ...retirementResults]
1423
+ localErrors.push(...[...peerResults, ...revokeResults, ...retirementResults]
1406
1424
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
1407
- .map((result) => result.reason);
1425
+ .map((result) => result.reason));
1426
+ await this.artifactStore.stop().catch((error) => localErrors.push(error));
1427
+ errors.push(...localErrors);
1428
+ await this.ownership.release('stop').catch((error) => errors.push(error));
1429
+ if (localErrors.length === 0) {
1430
+ this.localCleanupPending = false;
1431
+ this.clearStoppedRuntimeState();
1432
+ }
1408
1433
  if (errors.length > 0) {
1409
1434
  throw new AggregateError(errors, 'Browser runtime cleanup is incomplete');
1410
1435
  }
1411
- await this.artifactStore.stop();
1412
- if (this.lockHandle) {
1413
- if (!this.lockHandleClosed) {
1414
- await this.lockHandle.close();
1415
- this.lockHandleClosed = true;
1416
- }
1417
- await plugins.fsPromises.rm(this.lockPath, { force: true });
1418
- this.lockHandle = undefined;
1419
- this.lockHandleClosed = false;
1420
- }
1436
+ }
1437
+
1438
+ private clearStoppedRuntimeState(): void {
1439
+ this.profileRoot = undefined;
1421
1440
  this.slots.clear();
1441
+ this.capabilitiesByDigest.clear();
1442
+ this.capabilitiesById.clear();
1443
+ this.failedRevocations.clear();
1444
+ this.framedPeers.clear();
1422
1445
  this.retiredResourceIds.fill(0);
1423
1446
  this.retiredResourceKeys.fill(0);
1424
1447
  this.startPromise = undefined;
1425
1448
  }
1426
1449
 
1450
+ private async secureNewPrivateDirectory(pathArg: string): Promise<void> {
1451
+ const uid = this.options.environment.uid;
1452
+ if (uid === undefined) throw new BrowserRuntimeError('FENCED');
1453
+ await plugins.fsPromises.chmod(pathArg, 0o700);
1454
+ const handle = await plugins.fsPromises.open(
1455
+ pathArg,
1456
+ plugins.fs.constants.O_RDONLY
1457
+ | plugins.fs.constants.O_DIRECTORY
1458
+ | plugins.fs.constants.O_NOFOLLOW,
1459
+ );
1460
+ try {
1461
+ await handle.chmod(0o700);
1462
+ await plugins.fsPromises.chmod(pathArg, 0o700);
1463
+ const [pathStat, handleStat] = await Promise.all([
1464
+ plugins.fsPromises.lstat(pathArg, { bigint: true }),
1465
+ handle.stat({ bigint: true }),
1466
+ ]);
1467
+ if (
1468
+ !pathStat.isDirectory()
1469
+ || pathStat.isSymbolicLink()
1470
+ || pathStat.uid !== BigInt(uid)
1471
+ || (pathStat.mode & 0o777n) !== 0o700n
1472
+ || pathStat.dev !== handleStat.dev
1473
+ || pathStat.ino !== handleStat.ino
1474
+ ) throw new BrowserRuntimeError('FENCED', 'runtime directory is not private');
1475
+ } finally {
1476
+ await handle.close();
1477
+ }
1478
+ }
1479
+
1427
1480
  private requireRunning(): void {
1428
- if (this.lifecycleState !== 'running' || this.lifecycleController.signal.aborted) {
1481
+ if (
1482
+ this.lifecycleState !== 'running'
1483
+ || this.lifecycleController.signal.aborted
1484
+ || !this.ownership.owned
1485
+ ) {
1429
1486
  throw new BrowserRuntimeError('NOT_RUNNING');
1430
1487
  }
1431
1488
  }
1432
1489
 
1490
+ private requireArtifactStore(): BrowserArtifactStore {
1491
+ this.requireRunning();
1492
+ return this.artifactStore;
1493
+ }
1494
+
1495
+ private handleOwnershipLost(): void {
1496
+ if (this.lifecycleState === 'stopped' || this.lifecycleState === 'stopping') return;
1497
+ this.lifecycleEpoch += 1;
1498
+ this.lifecycleController.abort(new BrowserRuntimeError('FENCED'));
1499
+ this.lifecycleState = 'stopping';
1500
+ void this.stopAfterOwnershipLoss();
1501
+ }
1502
+
1503
+ private async stopAfterOwnershipLoss(): Promise<void> {
1504
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1505
+ try {
1506
+ await this.stop();
1507
+ return;
1508
+ } catch {
1509
+ if (!this.localCleanupPending && !this.ownership.cleanupPending) return;
1510
+ await Promise.resolve();
1511
+ }
1512
+ }
1513
+ }
1514
+
1433
1515
  private async acquireAgentLease(
1434
1516
  slot: IResourceSlot,
1435
1517
  capability: TCapabilityRecord,
@@ -1570,7 +1652,9 @@ export class BrowserRuntime {
1570
1652
  if (slot.session) return;
1571
1653
  this.assertSlotAvailable(slot);
1572
1654
  this.reserveLaunch(slot);
1573
- const profileDirectory = plugins.path.join(this.profileRoot, randomId(24));
1655
+ const profileRoot = this.profileRoot;
1656
+ if (!profileRoot) throw new BrowserRuntimeError('FENCED');
1657
+ const profileDirectory = plugins.path.join(profileRoot, randomId(24));
1574
1658
  let profileCreated = false;
1575
1659
  let proxy: BrowserEgressProxy | undefined;
1576
1660
  let session: ILiveBrowserSessionLike | undefined;
@@ -1578,7 +1662,7 @@ export class BrowserRuntime {
1578
1662
  try {
1579
1663
  await plugins.fsPromises.mkdir(profileDirectory, { mode: 0o700 });
1580
1664
  profileCreated = true;
1581
- await plugins.fsPromises.chmod(profileDirectory, 0o700);
1665
+ await this.secureNewPrivateDirectory(profileDirectory);
1582
1666
  proxy = new BrowserEgressProxy({
1583
1667
  projectId: slot.projectId,
1584
1668
  browserResourceId: slot.browserResourceId,
@@ -2199,7 +2283,7 @@ export class BrowserRuntime {
2199
2283
  signal.throwIfAborted();
2200
2284
  this.requireValidLease(lease);
2201
2285
  if (lease.slot.session !== session) throw new BrowserRuntimeError('ABORTED');
2202
- const artifact = await this.artifactStore.store(
2286
+ const artifact = await this.requireArtifactStore().store(
2203
2287
  lease.capability.projectId,
2204
2288
  lease.capability.browserResourceId,
2205
2289
  snapshot.mimeType,