rtrt-agent 0.1.2 → 0.1.4

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/README.ko.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # rtrt-agent
2
2
 
3
- `rtrt-agent`는 RTRT의 OpenCode provenance 플러그인입니다. 지원되는 OpenCode
4
- hook 사이에서 상위 session, agent, worktree, invocation 및 permission broker 문맥을
5
- 보존하며 broker는 loopback에서만 실행됩니다.
3
+ `rtrt-agent`는 RTRT의 OpenCode 통합입니다. 지원되는 hook 사이에서 상위 session,
4
+ agent, worktree, invocation 및 permission broker 문맥을 보존하고 로컬 대시보드를
5
+ 백그라운드에서 사용할 수 있게 유지합니다.
6
6
 
7
7
  ## 설치
8
8
 
9
9
  ```sh
10
- npm install rtrt-agent@0.1.2
10
+ npm install rtrt-agent@0.1.4
11
11
  ```
12
12
 
13
13
  `opencode.json`의 단수형 `plugin` 배열에 패키지를 등록합니다.
14
14
 
15
15
  ```json
16
16
  {
17
- "plugin": ["rtrt-agent@0.1.2"]
17
+ "plugin": ["rtrt-agent@0.1.4"]
18
18
  }
19
19
  ```
20
20
 
@@ -22,20 +22,41 @@ OpenCode는 이름 있는 `RtrtProvenance` export를 로드합니다. 이 패키
22
22
  default export를 제공하지 않습니다. 선택 사항인 RTRT TUI statusline은 별도 소스
23
23
  통합으로 유지되며 이 패키지에서 export하거나 pack하지 않습니다.
24
24
 
25
+ ## 대시보드
26
+
27
+ 플러그인을 로드하면 `rtrt-agent`는 `127.0.0.1:7311`에서 실행되는 사용자별 detached
28
+ `rtrt-dashboard` process를 예약합니다. 시작 과정은 대시보드 준비를 기다리지 않으며
29
+ 브라우저를 자동으로 열지 않습니다. 정확히 같은 버전의 플랫폼별 optional npm 패키지가
30
+ 실행 파일을 제공하므로 미리 설치된 `rtrt`, `PATH` 검색, install script 또는 project
31
+ build를 사용하지 않습니다.
32
+
33
+ 필요할 때만 브라우저를 엽니다.
34
+
35
+ ```sh
36
+ rtrt-dashboard-open
37
+ ```
38
+
39
+ 에이전트는 인자가 없는 `rtrt_dashboard_open` 도구로 같은 명시적 동작을 수행할 수
40
+ 있습니다. 두 경로 모두 60초 fragment bootstrap을 사용하고 고정 상태만 반환하므로
41
+ credential이 prompt나 command template에 들어가지 않습니다. 실행 파일 또는 private
42
+ state가 안전하지 않거나 없으면 시작만 fail-soft로 중단되고 OpenCode는 정상적으로
43
+ 계속됩니다. 기존 `~/.rtrt` 데이터는 보존합니다.
44
+
25
45
  `rtrt setup --agent opencode --apply`는 전체 RTRT 통합을 관리하지만 npm 설치는 수행하지
26
46
  않으며, 설정된 패키지는 OpenCode가 시작할 때 설치합니다. Setup은 먼저 정확한
27
- `rtrt-agent@0.1.2` 등록과 모든 대체 관리 asset을 기록합니다. 이 기록이 성공한 뒤에만
47
+ `rtrt-agent@0.1.4` 등록과 모든 대체 관리 asset을 기록합니다. 이 기록이 성공한 뒤에만
28
48
  인식된 legacy RTRT plugin을 제거하며, 이전 단계가 실패하면 legacy runtime을 보존합니다.
29
49
  Setup은 `rtrt-agent`의 정확히 일치하는 bare, pinned, ranged, tuple, object 형식만 소유하고
30
- 정확한 `"rtrt-agent@0.1.2"` string 하나로 정규화합니다. 이전의 미게시 `rtrt`와 초안
50
+ 정확한 `"rtrt-agent@0.1.4"` string 하나로 정규화합니다. 이전의 미게시 `rtrt`와 초안
31
51
  `rtrt-opencode` 패키지 spec은 외부 값이며 순서를 유지합니다. 위 직접 등록과
32
- `opencode plugin rtrt-agent@0.1.2 --global`은 계속 유효합니다.
52
+ `opencode plugin rtrt-agent@0.1.4 --global`은 계속 유효합니다.
33
53
 
34
54
  관리 agent 소유권 상태는 다음 순서의 첫 비어 있지 않은 root에서 읽습니다.
35
55
  `$OPENCODE_CONFIG_DIR`, `$XDG_CONFIG_HOME/opencode`, `~/.config/opencode`. 공존은 OMO
36
56
  4.19.4를 대상으로 CI에서 검사하고 OpenCode 1.18.29에서 직접 검증했으며, 어느 쪽도
37
- 미래 버전 지원을 보장하지 않습니다. 통합 RTRT 릴리스 워크플로는 정확한
38
- `rtrt-agent@0.1.2` 패키지를 일치하는 Rust 릴리스와 함께 게시합니다.
57
+ 미래 버전 지원을 보장하지 않습니다. 통합 RTRT 릴리스 워크플로는 정확한 버전의
58
+ 대시보드 플랫폼 패키지 5개를 `rtrt-agent@0.1.4`보다 먼저 게시한 뒤 일치하는 Rust
59
+ 릴리스 artifact를 게시합니다.
39
60
 
40
61
  ## 라이선스
41
62
 
package/README.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # rtrt-agent
2
2
 
3
- `rtrt-agent` is RTRT's OpenCode provenance plugin. It preserves parent session,
4
- agent, worktree, invocation, and permission-broker context across supported OpenCode
5
- hooks while keeping the broker loopback-only.
3
+ `rtrt-agent` is RTRT's OpenCode integration. It preserves parent session, agent,
4
+ worktree, invocation, and permission-broker context across supported hooks and keeps
5
+ the local dashboard available in the background.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```sh
10
- npm install rtrt-agent@0.1.2
10
+ npm install rtrt-agent@0.1.4
11
11
  ```
12
12
 
13
13
  Register the package in the singular `plugin` array in `opencode.json`:
14
14
 
15
15
  ```json
16
16
  {
17
- "plugin": ["rtrt-agent@0.1.2"]
17
+ "plugin": ["rtrt-agent@0.1.4"]
18
18
  }
19
19
  ```
20
20
 
@@ -22,21 +22,41 @@ OpenCode loads the named `RtrtProvenance` export. The package intentionally has
22
22
  default export. The optional RTRT TUI statusline remains a separate source integration
23
23
  and is not exported or packed by this package.
24
24
 
25
+ ## Dashboard
26
+
27
+ On plugin load, `rtrt-agent` schedules `rtrt-dashboard` as a detached per-user process
28
+ on `127.0.0.1:7311`. Startup never waits for dashboard readiness and never opens a
29
+ browser. The matching platform executable comes from an exact-version optional npm
30
+ package; no preinstalled `rtrt`, `PATH` lookup, install script, or project build is used.
31
+
32
+ Open the browser only when requested:
33
+
34
+ ```sh
35
+ rtrt-dashboard-open
36
+ ```
37
+
38
+ An agent may invoke the argument-free `rtrt_dashboard_open` tool for the same explicit
39
+ action. Both paths use a 60-second fragment bootstrap and return only a fixed status;
40
+ credentials are never written into prompts or command templates. Startup is fail-soft:
41
+ if the binary or private state is unsafe or unavailable, OpenCode continues normally.
42
+ Existing `~/.rtrt` data is preserved.
43
+
25
44
  `rtrt setup --agent opencode --apply` manages the full RTRT integration but performs no
26
45
  npm installation; OpenCode installs the configured package at startup. Setup first writes
27
- the exact `rtrt-agent@0.1.2` registration and every replacement managed asset. Only after
46
+ the exact `rtrt-agent@0.1.4` registration and every replacement managed asset. Only after
28
47
  those writes succeed does it remove a recognized legacy RTRT plugin; an earlier failure
29
48
  preserves the legacy runtime. Setup owns exact bare, pinned, ranged, tuple, and object forms
30
- of `rtrt-agent` only and normalizes them to one exact `"rtrt-agent@0.1.2"` string. Old
49
+ of `rtrt-agent` only and normalizes them to one exact `"rtrt-agent@0.1.4"` string. Old
31
50
  unpublished `rtrt` and draft `rtrt-opencode` package specs are foreign and retain their
32
- order. The direct registration above and `opencode plugin rtrt-agent@0.1.2 --global`
51
+ order. The direct registration above and `opencode plugin rtrt-agent@0.1.4 --global`
33
52
  remain valid.
34
53
 
35
54
  Managed-agent ownership state is read from the first nonempty root in this order:
36
55
  `$OPENCODE_CONFIG_DIR`, `$XDG_CONFIG_HOME/opencode`, then `~/.config/opencode`.
37
56
  Coexistence is CI-gated against OMO 4.19.4 and was verified on OpenCode 1.18.29; neither
38
57
  is a future-version guarantee. The unified RTRT release workflow publishes the exact
39
- `rtrt-agent@0.1.2` package together with the matching Rust release.
58
+ five exact-version dashboard platform packages before `rtrt-agent@0.1.4`, then publishes
59
+ the matching Rust release artifacts.
40
60
 
41
61
  ## License
42
62
 
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { realpath } from "node:fs/promises"
3
+ import { fileURLToPath } from "node:url"
4
+ import { createDashboardSupervisor } from "../runtime/dashboard-supervisor.js"
5
+
6
+ export async function runDashboardOpen({
7
+ open = () => createDashboardSupervisor().open(), write = (text) => process.stdout.write(text),
8
+ } = {}) {
9
+ let success = false
10
+ try { success = await open() === "Dashboard open requested." } catch {
11
+ // Process/browser failures may contain credentials; emit only a fixed status.
12
+ }
13
+ write(success ? "Dashboard open requested.\n" : "Dashboard unavailable.\n")
14
+ return success ? 0 : 1
15
+ }
16
+
17
+ if (process.argv[1] && await realpath(process.argv[1]).catch(() => "") === fileURLToPath(import.meta.url)) {
18
+ process.exitCode = await runDashboardOpen()
19
+ }
package/index.js CHANGED
@@ -1 +1,3 @@
1
- export { RtrtProvenance } from "./rtrt-provenance.js"
1
+ import { createDashboardPlugin } from "./runtime/dashboard-plugin.js"
2
+
3
+ export const RtrtProvenance = createDashboardPlugin()
package/package.json CHANGED
@@ -1,21 +1,31 @@
1
1
  {
2
2
  "name": "rtrt-agent",
3
- "version": "0.1.2",
4
- "description": "RTRT provenance and permission-broker plugin for OpenCode",
3
+ "version": "0.1.4",
4
+ "description": "RTRT provenance, permission-broker, and local dashboard plugin for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "exports": {
8
8
  ".": "./index.js"
9
9
  },
10
+ "bin": {
11
+ "rtrt-dashboard-open": "./bin/rtrt-dashboard-open.js"
12
+ },
10
13
  "files": [
11
14
  "index.js",
12
15
  "rtrt-provenance.js",
16
+ "bin/rtrt-dashboard-open.js",
17
+ "runtime/dashboard-binary.js",
18
+ "runtime/dashboard-files.js",
19
+ "runtime/dashboard-plugin.js",
20
+ "runtime/dashboard-process.js",
21
+ "runtime/dashboard-supervisor.js",
13
22
  "README.md",
14
23
  "README.ko.md",
15
24
  "LICENSE"
16
25
  ],
17
26
  "scripts": {
18
- "test": "node --test rtrt-provenance.test.mjs package.test.mjs tui/rtrt-statusline.test.mjs"
27
+ "test": "node --test rtrt-provenance.test.mjs dashboard-binary.test.mjs dashboard-open.test.mjs dashboard-supervisor.test.mjs package.test.mjs platform-package.test.mjs platform-publish.test.mjs tui/rtrt-statusline.test.mjs",
28
+ "sync:platforms": "node ../../packaging/npm/agent-contract.mjs --sync"
19
29
  },
20
30
  "engines": {
21
31
  "node": ">=20"
@@ -39,5 +49,12 @@
39
49
  },
40
50
  "dependencies": {
41
51
  "@opencode-ai/sdk": "1.15.13"
52
+ },
53
+ "optionalDependencies": {
54
+ "rtrt-dashboard-linux-x64": "0.1.4",
55
+ "rtrt-dashboard-linux-arm64": "0.1.4",
56
+ "rtrt-dashboard-darwin-x64": "0.1.4",
57
+ "rtrt-dashboard-darwin-arm64": "0.1.4",
58
+ "rtrt-dashboard-win32-x64": "0.1.4"
42
59
  }
43
60
  }
@@ -0,0 +1,69 @@
1
+ import { lstat, readFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import { readRegular, realPath, unavailable } from "./dashboard-files.js"
5
+
6
+ const PACKAGES = new Map([
7
+ ["linux-x64", "rtrt-dashboard-linux-x64"],
8
+ ["linux-arm64", "rtrt-dashboard-linux-arm64"],
9
+ ["darwin-x64", "rtrt-dashboard-darwin-x64"],
10
+ ["darwin-arm64", "rtrt-dashboard-darwin-arm64"],
11
+ ["win32-x64", "rtrt-dashboard-win32-x64"],
12
+ ])
13
+
14
+ // Node's package search layout, anchored to this plugin, without NODE_PATH,
15
+ // cwd, PATH, Cargo target directories, or runtime network downloads.
16
+ function packageRoots() {
17
+ const roots = []
18
+ let directory = fileURLToPath(new URL("../", import.meta.url))
19
+ for (;;) {
20
+ if (path.basename(directory) !== "node_modules") roots.push(path.join(directory, "node_modules"))
21
+ const parent = path.dirname(directory)
22
+ if (parent === directory) return roots
23
+ directory = parent
24
+ }
25
+ }
26
+
27
+ export async function resolveDashboardBinary({
28
+ home, platform = process.platform, arch = process.arch,
29
+ version, roots = packageRoots(), uid = process.geteuid?.(),
30
+ }) {
31
+ const name = PACKAGES.get(`${platform}-${arch}`)
32
+ if (!name) throw unavailable()
33
+ const expected = version ?? JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version
34
+ if (typeof expected !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expected)) throw unavailable()
35
+ const validate = (stat) => {
36
+ if (stat.isSymbolicLink()) throw unavailable()
37
+ if (platform !== "win32" && ((stat.uid !== uid && stat.uid !== 0) || (stat.mode & 0o7022) !== 0)) throw unavailable()
38
+ }
39
+ const cache = path.join(home, ".rtrt", "dashboard", "packages", expected, "node_modules")
40
+ for (const root of [...roots, cache]) {
41
+ const directory = path.join(root, name)
42
+ try {
43
+ await realPath(directory)
44
+ // Check ancestors too: a private executable in a writable parent is not trusted.
45
+ let ancestor = directory
46
+ for (;;) {
47
+ const stat = await lstat(ancestor)
48
+ if (!stat.isDirectory()) throw unavailable()
49
+ // System-owned sticky temp roots are safe when all descendants are checked.
50
+ if (!(stat.uid === 0 && (stat.mode & 0o1777) === 0o1777)) validate(stat)
51
+ const parent = path.dirname(ancestor)
52
+ if (parent === ancestor) break
53
+ ancestor = parent
54
+ }
55
+ const manifest = JSON.parse(await readRegular(path.join(directory, "package.json"), validate))
56
+ if (manifest.name !== name || manifest.version !== expected) continue
57
+ const binary = path.join(directory, "bin", platform === "win32" ? "rtrt-dashboard.exe" : "rtrt-dashboard")
58
+ await realPath(binary)
59
+ validate(await lstat(path.dirname(binary)))
60
+ const stat = await lstat(binary)
61
+ validate(stat)
62
+ if (!stat.isFile() || stat.nlink !== 1 || (platform !== "win32" && (stat.mode & 0o111) === 0)) throw unavailable()
63
+ return binary
64
+ } catch (error) {
65
+ if (error.code !== "ENOENT") throw unavailable()
66
+ }
67
+ }
68
+ throw unavailable()
69
+ }
@@ -0,0 +1,104 @@
1
+ import { constants } from "node:fs"
2
+ import { lstat, mkdir, open, rmdir } from "node:fs/promises"
3
+ import path from "node:path"
4
+
5
+ export const unavailable = () => new Error("Dashboard unavailable.")
6
+
7
+ export async function realPath(candidate) {
8
+ if (typeof candidate !== "string" || !path.isAbsolute(candidate) || candidate.includes("\0")) throw unavailable()
9
+ const normalized = path.resolve(candidate)
10
+ const parts = normalized.slice(path.parse(normalized).root.length).split(path.sep).filter(Boolean)
11
+ let current = path.parse(normalized).root
12
+ for (const part of parts) {
13
+ current = path.join(current, part)
14
+ if ((await lstat(current)).isSymbolicLink()) throw unavailable()
15
+ }
16
+ return normalized
17
+ }
18
+
19
+ export function privateMetadata(stat, { uid, platform }, mode) {
20
+ if (stat.isSymbolicLink()) throw unavailable()
21
+ if (platform !== "win32" && (stat.uid !== uid || (stat.mode & 0o7777) !== mode)) throw unavailable()
22
+ }
23
+
24
+ export async function readRegular(file, validate, limit = 16_384) {
25
+ const before = await lstat(file)
26
+ validate(before)
27
+ if (!before.isFile() || before.nlink !== 1 || before.size > limit) throw unavailable()
28
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
29
+ try {
30
+ const opened = await handle.stat()
31
+ validate(opened)
32
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw unavailable()
33
+ const buffer = Buffer.alloc(limit + 1)
34
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
35
+ if (bytesRead > limit) throw unavailable()
36
+ return buffer.subarray(0, bytesRead).toString("utf8")
37
+ } finally {
38
+ await handle.close()
39
+ }
40
+ }
41
+
42
+ export function dashboardFiles({ home, uid, platform }) {
43
+ const identity = { uid, platform }
44
+ const state = path.join(home, ".rtrt", "dashboard")
45
+ const credential = path.join(state, "dashboard.env")
46
+ const lock = path.join(state, "startup.lock")
47
+ const directory = async (candidate) => {
48
+ const stat = await lstat(candidate)
49
+ privateMetadata(stat, identity, 0o700)
50
+ if (!stat.isDirectory()) throw unavailable()
51
+ }
52
+ const checkLock = async () => {
53
+ try { await directory(lock) } catch (error) {
54
+ if (error.code !== "ENOENT") throw error
55
+ }
56
+ }
57
+ const prepare = async () => {
58
+ await realPath(home)
59
+ const stat = await lstat(home)
60
+ if (!stat.isDirectory() || (platform !== "win32" && (stat.uid !== uid || (stat.mode & 0o022) !== 0))) throw unavailable()
61
+ for (const candidate of [path.dirname(state), state]) {
62
+ try { await mkdir(candidate, { mode: 0o700 }) } catch (error) {
63
+ if (error.code !== "EEXIST") throw error
64
+ }
65
+ await directory(candidate)
66
+ }
67
+ await checkLock()
68
+ }
69
+ const readToken = async () => {
70
+ await realPath(state)
71
+ let raw
72
+ try { raw = await readRegular(credential, (stat) => privateMetadata(stat, identity, 0o600)) } catch (error) {
73
+ if (error.code === "ENOENT") return undefined
74
+ throw error
75
+ }
76
+ const entries = raw.split(/\r?\n/).filter((line) => /^\s*RTRT_DASHBOARD_TOKEN\s*=/.test(line))
77
+ if (entries.length !== 1) throw unavailable()
78
+ const match = entries[0].match(/^\s*RTRT_DASHBOARD_TOKEN\s*=\s*(?:"([a-fA-F0-9]{64})"|'([a-fA-F0-9]{64})'|([a-fA-F0-9]{64}))\s*$/)
79
+ if (!match) throw unavailable()
80
+ return match[1] ?? match[2] ?? match[3]
81
+ }
82
+ const createToken = async (token) => {
83
+ if (!/^[a-fA-F0-9]{64}$/.test(token)) throw unavailable()
84
+ const handle = await open(credential, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600)
85
+ try {
86
+ privateMetadata(await handle.stat(), identity, 0o600)
87
+ await handle.writeFile(`RTRT_DASHBOARD_TOKEN=${token}\n`)
88
+ await handle.sync()
89
+ } finally {
90
+ await handle.close()
91
+ }
92
+ }
93
+ const acquire = async () => {
94
+ try { await mkdir(lock, { mode: 0o700 }) } catch (error) {
95
+ if (error.code !== "EEXIST") throw error
96
+ await directory(lock)
97
+ return undefined
98
+ }
99
+ await directory(lock)
100
+ // Never steal a lock on age/PID guesses, or remove another starter's lock.
101
+ return () => rmdir(lock)
102
+ }
103
+ return { state, prepare, readToken, createToken, acquire }
104
+ }
@@ -0,0 +1,32 @@
1
+ import { RtrtProvenance } from "../rtrt-provenance.js"
2
+ import { createDashboardSupervisor } from "./dashboard-supervisor.js"
3
+
4
+ const scheduleStartup = (callback) => setTimeout(callback, 0).unref()
5
+
6
+ export function createDashboardPlugin({
7
+ provenance = RtrtProvenance, supervisor = createDashboardSupervisor(), schedule = scheduleStartup,
8
+ } = {}) {
9
+ return async (input) => {
10
+ const hooks = await provenance(input)
11
+ // Startup is best-effort and never blocks plugin initialization or opens a browser.
12
+ schedule(() => { void supervisor.ensure().catch(() => {}) })
13
+ return {
14
+ ...hooks,
15
+ tool: {
16
+ ...hooks.tool,
17
+ rtrt_dashboard_open: {
18
+ description: "Open the RTRT dashboard in the operator's browser when explicitly requested.",
19
+ args: {},
20
+ async execute() {
21
+ try {
22
+ const status = await supervisor.open()
23
+ return status === "Dashboard open requested." ? status : "Dashboard unavailable."
24
+ } catch {
25
+ return "Dashboard unavailable."
26
+ }
27
+ },
28
+ },
29
+ },
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,59 @@
1
+ import { spawn } from "node:child_process"
2
+ import { get } from "node:http"
3
+ import path from "node:path"
4
+ import { unavailable } from "./dashboard-files.js"
5
+
6
+ export function probeDashboard({ request = get, timeout = 750 } = {}) {
7
+ return new Promise((resolve) => {
8
+ let settled = false
9
+ let requestHandle
10
+ const finish = (result) => {
11
+ if (settled) return
12
+ settled = true
13
+ clearTimeout(timer)
14
+ requestHandle?.destroy()
15
+ resolve(result)
16
+ }
17
+ const timer = setTimeout(() => finish("conflict"), timeout)
18
+ try {
19
+ requestHandle = request({ hostname: "127.0.0.1", port: 7311, path: "/healthz", agent: false }, (response) => {
20
+ let body = ""
21
+ response.on("error", () => finish("conflict"))
22
+ response.on("aborted", () => finish("conflict"))
23
+ response.on("data", (chunk) => {
24
+ body += chunk.toString("utf8")
25
+ if (body.length > 16) finish("conflict")
26
+ })
27
+ response.on("end", () => finish(response.statusCode === 200 && body === "ok" ? "healthy" : "conflict"))
28
+ })
29
+ requestHandle.once("error", (error) => finish(error.code === "ECONNREFUSED" ? "offline" : "conflict"))
30
+ } catch {
31
+ finish("conflict")
32
+ }
33
+ })
34
+ }
35
+
36
+ export function launchDetached(binary, args, { spawnProcess = spawn, ...options } = {}) {
37
+ return new Promise((resolve, reject) => {
38
+ if (!path.isAbsolute(binary)) { reject(unavailable()); return }
39
+ try {
40
+ const child = spawnProcess(binary, args, {
41
+ ...options, detached: true, windowsHide: true, stdio: "ignore", shell: false,
42
+ })
43
+ child.once("error", () => reject(unavailable()))
44
+ child.once("spawn", () => { child.unref(); resolve() })
45
+ } catch {
46
+ reject(unavailable())
47
+ }
48
+ })
49
+ }
50
+
51
+ export async function openDashboardBrowser(url, { platform = process.platform, launch = launchDetached } = {}) {
52
+ // Never consult BROWSER or PATH. URL is generated locally, not supplied by a tool argument.
53
+ switch (platform) {
54
+ case "linux": return launch("/usr/bin/xdg-open", [url])
55
+ case "darwin": return launch("/usr/bin/open", [url])
56
+ case "win32": return launch("C:\\Windows\\System32\\rundll32.exe", ["url.dll,FileProtocolHandler", url])
57
+ default: throw unavailable()
58
+ }
59
+ }
@@ -0,0 +1,96 @@
1
+ import { createHmac, randomBytes as systemRandomBytes } from "node:crypto"
2
+ import { homedir } from "node:os"
3
+ import { setTimeout as delay } from "node:timers/promises"
4
+ import { dashboardFiles, unavailable } from "./dashboard-files.js"
5
+ import { resolveDashboardBinary } from "./dashboard-binary.js"
6
+ import { launchDetached, openDashboardBrowser, probeDashboard } from "./dashboard-process.js"
7
+
8
+ export function createDashboardSupervisor({
9
+ env = process.env, platform = process.platform, arch = process.arch, uid = process.geteuid?.(),
10
+ probe = probeDashboard, resolveBinary = resolveDashboardBinary, launch = launchDetached,
11
+ openBrowser = openDashboardBrowser, wait = delay, now = Date.now, randomBytes = systemRandomBytes,
12
+ } = {}) {
13
+ const home = env.HOME || env.USERPROFILE || homedir()
14
+ const files = dashboardFiles({ home, uid, platform })
15
+ let pending
16
+ const check = async () => {
17
+ const status = await probe()
18
+ switch (status) {
19
+ case "healthy": return true
20
+ case "offline": return false
21
+ case "conflict": throw unavailable()
22
+ default: throw unavailable()
23
+ }
24
+ }
25
+ const start = async () => {
26
+ await files.prepare()
27
+ const token = await files.readToken()
28
+ if (await check()) {
29
+ if (!token) throw unavailable()
30
+ return
31
+ }
32
+ let release
33
+ for (let attempt = 0; attempt < 20; attempt++) {
34
+ release = await files.acquire()
35
+ if (release) break
36
+ await wait(100)
37
+ await files.prepare()
38
+ if (await check()) {
39
+ if (!await files.readToken()) throw unavailable()
40
+ return
41
+ }
42
+ }
43
+ if (!release) throw unavailable()
44
+ try {
45
+ // A different process may have completed startup between first probe and lock.
46
+ if (await check()) {
47
+ if (!await files.readToken()) throw unavailable()
48
+ return
49
+ }
50
+ const binary = await resolveBinary({ home, platform, arch, uid })
51
+ if (!await files.readToken()) await files.createToken(randomBytes(32).toString("hex"))
52
+ // Keep operator environment, but never inherit loader injection or dashboard credentials.
53
+ const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) =>
54
+ !/^(?:LD_|DYLD_|RTRT_DASHBOARD_|NODE_OPTIONS$)/i.test(key)))
55
+ await launch(binary, ["--machine", "--state-dir", files.state], {
56
+ cwd: home,
57
+ env: { ...childEnv, HOME: home, RTRT_DASHBOARD_BIND: "127.0.0.1:7311" },
58
+ })
59
+ for (let attempt = 0; attempt < 20; attempt++) {
60
+ if (await check()) return
61
+ await wait(100)
62
+ }
63
+ throw unavailable()
64
+ } finally {
65
+ await release()
66
+ }
67
+ }
68
+ const ensure = () => {
69
+ if (!pending) {
70
+ pending = start().catch(() => { throw unavailable() }).finally(() => { pending = undefined })
71
+ }
72
+ return pending
73
+ }
74
+ const open = async () => {
75
+ try {
76
+ await ensure()
77
+ const token = await files.readToken()
78
+ if (!token) throw unavailable()
79
+ const issued = BigInt(Math.floor(now() / 1000))
80
+ const wire = Buffer.alloc(65)
81
+ wire[0] = 1
82
+ wire.writeBigUInt64BE(issued, 1)
83
+ wire.writeBigUInt64BE(issued + 60n, 9)
84
+ randomBytes(16).copy(wire, 17)
85
+ createHmac("sha256", Buffer.from(token, "hex"))
86
+ .update("rtrt-dashboard-browser-bootstrap\0v1\0")
87
+ .update(wire.subarray(0, 33)).digest().copy(wire, 33)
88
+ await openBrowser(`http://127.0.0.1:7311/#bootstrap=${wire.toString("base64url")}`, { platform })
89
+ return "Dashboard open requested."
90
+ } catch {
91
+ // Never expose dependency errors: process errors can contain the bootstrap URL.
92
+ return "Dashboard unavailable."
93
+ }
94
+ }
95
+ return { ensure, open }
96
+ }