@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.
- package/changelog.md +24 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.artifactstore.d.ts +4 -1
- package/dist_ts/classes.artifactstore.js +42 -11
- package/dist_ts/classes.runtime.d.ts +8 -5
- package/dist_ts/classes.runtime.js +126 -48
- package/dist_ts/classes.runtimeownership.d.ts +102 -0
- package/dist_ts/classes.runtimeownership.js +1230 -0
- package/dist_ts/internal.testing.d.ts +2 -0
- package/dist_ts/internal.testing.js +1 -1
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +3 -2
- package/readme.hints.md +7 -0
- package/readme.md +23 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.artifactstore.ts +51 -14
- package/ts/classes.runtime.ts +132 -48
- package/ts/classes.runtimeownership.ts +1393 -0
- package/ts/internal.testing.ts +2 -0
- package/ts/plugins.ts +2 -1
|
@@ -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,
|
|
2
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJuYWwudGVzdGluZy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL2ludGVybmFsLnRlc3RpbmcudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBVUEsTUFBTSxDQUFDLE1BQU0scUJBQXFCLEdBQUcsTUFBTSxDQUFDLHVCQUF1QixDQUFDLENBQUMifQ==
|
package/dist_ts/plugins.d.ts
CHANGED
|
@@ -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 };
|
package/dist_ts/plugins.js
CHANGED
|
@@ -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,
|
|
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
|
+
"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
|
|
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
|
|
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()`
|
|
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
|
|
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
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@modelprofile.com/browser-runtime',
|
|
6
|
-
version: '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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]
|
package/ts/classes.runtime.ts
CHANGED
|
@@ -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
|
|
237
|
-
private
|
|
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.
|
|
483
|
-
|
|
484
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
532
|
-
|
|
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.
|
|
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.
|
|
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
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
-
|
|
1368
|
-
this.
|
|
1369
|
-
|
|
1370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
2286
|
+
const artifact = await this.requireArtifactStore().store(
|
|
2203
2287
|
lease.capability.projectId,
|
|
2204
2288
|
lease.capability.browserResourceId,
|
|
2205
2289
|
snapshot.mimeType,
|