rtrt-agent 0.1.1 → 0.1.3
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 +31 -10
- package/README.md +29 -9
- package/bin/rtrt-dashboard-open.js +19 -0
- package/index.js +3 -1
- package/package.json +20 -3
- package/runtime/dashboard-binary.js +69 -0
- package/runtime/dashboard-files.js +104 -0
- package/runtime/dashboard-plugin.js +32 -0
- package/runtime/dashboard-process.js +59 -0
- package/runtime/dashboard-supervisor.js +96 -0
package/README.ko.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# rtrt-agent
|
|
2
2
|
|
|
3
|
-
`rtrt-agent`는 RTRT의 OpenCode
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
10
|
+
npm install rtrt-agent@0.1.3
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
`opencode.json`의 단수형 `plugin` 배열에 패키지를 등록합니다.
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
16
|
{
|
|
17
|
-
"plugin": ["rtrt-agent@0.1.
|
|
17
|
+
"plugin": ["rtrt-agent@0.1.3"]
|
|
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.
|
|
47
|
+
`rtrt-agent@0.1.3` 등록과 모든 대체 관리 asset을 기록합니다. 이 기록이 성공한 뒤에만
|
|
28
48
|
인식된 legacy RTRT plugin을 제거하며, 이전 단계가 실패하면 legacy runtime을 보존합니다.
|
|
29
49
|
Setup은 `rtrt-agent`의 정확히 일치하는 bare, pinned, ranged, tuple, object 형식만 소유하고
|
|
30
|
-
정확한 `"rtrt-agent@0.1.
|
|
50
|
+
정확한 `"rtrt-agent@0.1.3"` string 하나로 정규화합니다. 이전의 미게시 `rtrt`와 초안
|
|
31
51
|
`rtrt-opencode` 패키지 spec은 외부 값이며 순서를 유지합니다. 위 직접 등록과
|
|
32
|
-
`opencode plugin rtrt-agent@0.1.
|
|
52
|
+
`opencode plugin rtrt-agent@0.1.3 --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.
|
|
57
|
+
미래 버전 지원을 보장하지 않습니다. 통합 RTRT 릴리스 워크플로는 정확한 버전의
|
|
58
|
+
대시보드 플랫폼 패키지 5개를 `rtrt-agent@0.1.3`보다 먼저 게시한 뒤 일치하는 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
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
10
|
+
npm install rtrt-agent@0.1.3
|
|
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.
|
|
17
|
+
"plugin": ["rtrt-agent@0.1.3"]
|
|
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.
|
|
46
|
+
the exact `rtrt-agent@0.1.3` 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.
|
|
49
|
+
of `rtrt-agent` only and normalizes them to one exact `"rtrt-agent@0.1.3"` 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.
|
|
51
|
+
order. The direct registration above and `opencode plugin rtrt-agent@0.1.3 --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.
|
|
58
|
+
five exact-version dashboard platform packages before `rtrt-agent@0.1.3`, 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
package/package.json
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rtrt-agent",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "RTRT provenance
|
|
3
|
+
"version": "0.1.3",
|
|
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.3",
|
|
55
|
+
"rtrt-dashboard-linux-arm64": "0.1.3",
|
|
56
|
+
"rtrt-dashboard-darwin-x64": "0.1.3",
|
|
57
|
+
"rtrt-dashboard-darwin-arm64": "0.1.3",
|
|
58
|
+
"rtrt-dashboard-win32-x64": "0.1.3"
|
|
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
|
+
}
|