Files
dsh_shenxian/src/net/relay/instance-credential.ts
T
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

516 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* **服务器实例的覆盖网络设备凭据**:平台侧代签 + 投递到实例 home + 续签(序47 · S4/S5)。
*
* ## 为什么必须是"平台侧代签"
* 本版平台**不存在**「实例 → 平台」的身份通道(交接单序47 §②.1 取证,⛔ 不许改判):
* `requireAuth` 只认 `sid` cookie、`POST /api/auth/login` 只收 `username + password`
* (而平台不存明文口令、也从不把口令投给实例)、实例启动时平台投递的 env 里没有任何平台身份凭据。
* ⇒ 实例**不可能**自己去调那份"用户态签发端点",只能由**控制面以 `userId` 归属为据代签**。
* 签发本身走的是**唯一实现** `device-grant.ts#issueDeviceGrant`(⛔ 本文件不复制那段判据)。
*
* ## 三件事,各自只有一处
* | 关注点 | 落点 |
* |---|---|
* | 怎么签、按什么顺序落四处、失败怎么具名 | `device-grant.ts#issueDeviceGrant`(序47 S4 已抽) |
* | **何时**签 / 何时续签 / 凭据文件长什么样 | 本文件 |
* | 何时触发(实例启动 / 定时续签) | `supervisor/orchestrator.ts` · `remote-spawner.ts` · `web/server.ts` |
*
* ## 🔴 五条硬纪律
* ① **写实例 home 一律走 `UserFs`** —— 实例可能在 worker 上,直接 `fs` = **静默空操作**
* (档案 138 §五:读回空串、不报错)。本文件**不 import `node:fs` 写用户卷**。
* ② **密钥不落平台**:私钥由平台生成后**只写进实例 home**(§④ 决策 6),平台不留副本、
* ⛔ 不进日志、⛔ 不进审计 detail;⛔ **绝不**走 `home-files.ts#backupHomeFile`
* ——那会把私钥复制进平台备份目录,等于自建一份副本。
* ③ **⛔ 不改判、不重建**:凭据文件存在但形状非法 ⇒ **具名拒绝**(`credential-file-corrupt`),
* ⛔ 不"修一下再看"。原因:重建必然生成新密钥对 ⇒ **hostId 改变**(`d-<uid>-<公钥前8位>`)
* ⇒ 换一台"设备",会绕过针对旧 hostId 的停发(`status='revoked'`)并多占一个配额位。
* 处置 = 人工确认后**删除该文件**,下一次触发即重新签发。
* ④ **续签复用同一把 nodeKey**(读回文件里的私钥)⇒ hostId 不变 ⇒ 台账那一行是**同一条**
* (`grant_renewals + 1`),⛔ 不产生"第二条实例设备"。
* ⑤ **每用户至多一条活跃的 `kind='instance'` 设备** —— 平台本来就"一账号一个 main 实例"。
* 因此新签发(换 hostId)时,把该用户名下**其它** `kind='instance'` 条目**收编**:
* 台账置 `revoked` + 从 registry 移除。⛔ 这不是"顺手多做",而是 ②③ 的直接补偿:
* 没有它,"删文件重建"这条**我们自己写进回滚/处置流程**的动作会静默烧掉配额位,
* 并在用户网里永久留一台查不到主人的已批准设备。
*
* @module dshs/net/relay/instance-credential
*/
import type { DbAdapter } from '../../db/adapter.js'
import type { OverlayDevice } from '../../db/types.js'
import type { HomeFileName, UserFs } from '../../fs/user-fs.js'
import {
DEVICE_GRANT_TTL_HOURS_DEFAULT,
envNum,
issueDeviceGrant,
registryFile,
tenantNetworkOf,
type DeviceGrantDb,
type IssueDeviceGrantInput,
} from './device-grant.js'
import { generateNodeKey, publicKeyOfPrivate } from './identity.js'
import { loadRegistry, removeNode, saveRegistry } from './registry.js'
/** 实例凭据在实例 home 里的**固定文件名**(`UserFs` 白名单之一,⛔ 不收路径)。 */
export const INSTANCE_DEVICE_FILE: HomeFileName = '.overlay-device.json'
/** 凭据文件的结构版本(形状变了就 +1,让老文件能被**具名**判成不可用而不是半读)。 */
export const INSTANCE_DEVICE_FILE_VERSION = 1
/** 设备来源标签 —— 与 v13 的 `overlay_devices.kind` 值域同源(⛔ 不另立一套)。 */
export const INSTANCE_DEVICE_KIND = 'instance' as const
/** 分组标签(进 registry 的 `group`,纯展示 / 分桶)。 */
export const INSTANCE_DEVICE_GROUP = 'instance'
/**
* 续签扫描间隔(ms)。**中性默认 5 分钟**,外置键 `DSHS_INSTANCE_GRANT_RENEW_MS`。
* `0` ⇒ 关闭定时续签(只剩"实例启动时检查"这一条触发)。
*/
export const INSTANCE_GRANT_RENEW_MS_DEFAULT = 300_000
/**
* 剩余租约**低于 TTL 的这个比例**才续签。
*
* 为什么不是"临到期才续":续签要经 `UserFs`(集群形态下一次跨机写)+ 一次台账写 +
* 一次 registry 写,三步都可能失败;留 50% 的余量意味着**每一步都还有十几次重试机会**
* (5 分钟一次),而不是"最后一次机会失败 = 实例掉网"。
* ⚠️ 复用同一把 HMAC 密钥 ⇒ 续签**不轮换**密钥 ⇒ 这个偏保守的比例不会带来
* "实例还没读回新文件就被拒"的窗口(`HELLO` 的 MAC 用的是同一把)。
*/
export const INSTANCE_GRANT_RENEW_FRACTION = 0.5
/** 凭据文件的内容 —— 实例侧拨号客户端要的就是这些(⛔ 平台侧只读它的公钥部分)。 */
export interface InstanceDeviceCredential {
version: number
kind: typeof INSTANCE_DEVICE_KIND
network: string
hostId: string
/** 节点**公钥**(裸 64 hex)。 */
nodeKey: string
/** 节点**私钥**(PEM)—— 只存在于实例 home,平台不留副本。 */
nodePrivateKeyPem: string
/** relay `HELLO` 的 MAC 密钥(hex)。只存在于实例 home + relay 的密钥表。 */
hmacSecret: string
/** 平台签名者签发的入网凭据(`{doc, sig}`,直接随 `HELLO` 出示)。 */
grant: { doc: unknown; sig: string }
issuedAt: string
expiresAt: string
renewals: number
}
/** 本模块用到的台账读写(显式声明 ⇒ 调用方提供的桩不必实现整个 `DbAdapter`)。 */
export type InstanceCredentialDb = DeviceGrantDb &
Pick<DbAdapter, 'listOverlayDevices' | 'setOverlayDeviceStatus'>
export interface InstanceCredentialDeps {
db: InstanceCredentialDb
/**
* ⚠️ **只要这两个方法**:写用户卷必须走本 seam(纪律 ①)。
* 生产传 `app.userFs`(本地 / 集群两种实现都满足),测试传内存桩。
*/
userFs: Pick<UserFs, 'readHomeFile' | 'writeHomeFile'>
}
export type InstanceCredentialAction = 'unchanged' | 'issued' | 'renewed'
/** 成功回执 —— ⛔ **不含任何密钥材料**(可安全打印 / 可进日志)。 */
export interface InstanceCredentialOk {
ok: true
action: InstanceCredentialAction
file: HomeFileName
userId: string
network: string
hostId: string
expiresAt: string
renewals: number
/** 距租约到期的剩余毫秒(可负 = 已过期)。 */
remainingMs: number
/** 这次是否真的动了 relay 密钥表(续签复用同一把 ⇒ `false`)。 */
relayKeyRotated: boolean
/** 被本次签发**收编**的旧实例设备 hostId(纪律 ⑤;正常续签恒为空)。 */
superseded: string[]
/** 非致命备注(如"收编台账失败")—— 有值时调用方**应当**打出来(⛔ 不静默吞)。 */
detail?: string
}
/** 失败 —— `reason` 一律具名(⛔ 不许把"配置错"写成"功能没生效")。 */
export interface InstanceCredentialErr {
ok: false
reason: string
detail: string
/** 有 HTTP 语义时给出(取自 `issueDeviceGrant`),便于调用方对齐既有原因码。 */
status?: number
file: HomeFileName
userId: string
/** 失败发生在"已经换了 hostId"之后时带上(便于排查),否则 `undefined`。 */
hostId?: string
}
export type InstanceCredentialResult = InstanceCredentialOk | InstanceCredentialErr
function msg(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
function err(
base: { userId: string },
reason: string,
detail: string,
extra: { status?: number; hostId?: string } = {},
): InstanceCredentialErr {
return { ok: false, reason, detail, file: INSTANCE_DEVICE_FILE, userId: base.userId, ...extra }
}
const HEX64 = /^[0-9a-f]{64}$/
/**
* 解析凭据文件。**严格**:任一条不合规 ⇒ `undefined`(调用方按 `credential-file-corrupt` 处理)。
*
* ⚠️ 校验里包含"**公钥与私钥确实是同一对**"—— 这两者不一致时,凭据在拨号那一刻才会以
* `identity-bad-proof` 失败,而那个症状看着像"网络问题";在这里判掉,故障就发生在
* **写入前**,且成因只有一种。
*/
export function parseInstanceCredential(text: string): InstanceDeviceCredential | undefined {
let raw: unknown
try {
raw = JSON.parse(text)
} catch {
return undefined
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
const r = raw as Record<string, unknown>
if (r.version !== INSTANCE_DEVICE_FILE_VERSION) return undefined
if (r.kind !== INSTANCE_DEVICE_KIND) return undefined
const network = typeof r.network === 'string' ? r.network : ''
const hostId = typeof r.hostId === 'string' ? r.hostId : ''
const nodeKey = typeof r.nodeKey === 'string' ? r.nodeKey.trim().toLowerCase() : ''
const nodePrivateKeyPem = typeof r.nodePrivateKeyPem === 'string' ? r.nodePrivateKeyPem : ''
const hmacSecret = typeof r.hmacSecret === 'string' ? r.hmacSecret.trim().toLowerCase() : ''
if (network === '' || hostId === '') return undefined
if (!HEX64.test(nodeKey) || !HEX64.test(hmacSecret)) return undefined
if (!nodePrivateKeyPem.includes('PRIVATE KEY')) return undefined
const grant = r.grant as { doc?: unknown; sig?: unknown } | undefined
if (grant === undefined || typeof grant !== 'object' || Array.isArray(grant)) return undefined
if (grant.doc === null || typeof grant.doc !== 'object') return undefined
if (typeof grant.sig !== 'string' || grant.sig === '') return undefined
// 公私钥必须成对(见本函数头注)。
try {
if (publicKeyOfPrivate(nodePrivateKeyPem) !== nodeKey) return undefined
} catch {
return undefined
}
const issuedAt = typeof r.issuedAt === 'string' ? r.issuedAt : ''
const expiresAt = typeof r.expiresAt === 'string' ? r.expiresAt : ''
if (Number.isNaN(Date.parse(issuedAt)) || Number.isNaN(Date.parse(expiresAt))) return undefined
const renewals = typeof r.renewals === 'number' && Number.isFinite(r.renewals) ? r.renewals : 0
return {
version: INSTANCE_DEVICE_FILE_VERSION,
kind: INSTANCE_DEVICE_KIND,
network,
hostId,
nodeKey,
nodePrivateKeyPem,
hmacSecret,
grant: { doc: grant.doc, sig: grant.sig },
issuedAt,
expiresAt,
renewals,
}
}
/** 把凭据序列化成**落盘文本**(缩进 2 + 结尾换行,与仓库其它 JSON 落盘一致)。 */
export function serializeInstanceCredential(cred: InstanceDeviceCredential): string {
return `${JSON.stringify(cred, null, 2)}\n`
}
function ttlMs(): number {
return envNum('DESKTOP_GRANT_TTL_HOURS', DEVICE_GRANT_TTL_HOURS_DEFAULT) * 3_600_000
}
function okOf(
action: InstanceCredentialAction,
cred: { userId: string; network: string; hostId: string; expiresAt: string; renewals: number },
nowMs: number,
relayKeyRotated: boolean,
superseded: string[],
): InstanceCredentialOk {
return {
ok: true,
action,
file: INSTANCE_DEVICE_FILE,
userId: cred.userId,
network: cred.network,
hostId: cred.hostId,
expiresAt: cred.expiresAt,
renewals: cred.renewals,
remainingMs: Date.parse(cred.expiresAt) - nowMs,
relayKeyRotated,
superseded,
}
}
/**
* **收编**该用户名下其它 `kind='instance'` 台账条目(纪律 ⑤)。
*
* 台账置 `revoked`(⇒ `issueDeviceGrant` 的停发闸门会拒它,⛔ 不能"续一次签就复活")
* + 从 registry 移除(⇒ 立刻退出派生白名单)。
*
* ⛔ **失败不阻断新签发**:新凭据已经签好了,为一台"已经没凭据的旧设备"回滚新凭据,
* 结果是**两边都没有凭据** —— 那是净变差(R11)。故只记在返回值里,由调用方具名。
*/
async function supersedeOtherInstanceDevices(
deps: InstanceCredentialDeps,
userId: string,
keepHostId: string,
): Promise<{ superseded: string[]; notes: string[] }> {
const network = tenantNetworkOf(userId)
let rows: OverlayDevice[]
try {
rows = await deps.db.listOverlayDevices(network)
} catch (e) {
return { superseded: [], notes: [`台账不可读,未做收编:${msg(e)}`] }
}
const stale = rows.filter((d) => d.kind === INSTANCE_DEVICE_KIND && d.hostId !== keepHostId)
if (stale.length === 0) return { superseded: [], notes: [] }
const notes: string[] = []
const done: string[] = []
for (const d of stale) {
try {
await deps.db.setOverlayDeviceStatus(network, d.hostId, 'revoked')
done.push(d.hostId)
} catch (e) {
notes.push(`台账收编失败 ${d.hostId}:${msg(e)}`)
}
}
try {
const file = registryFile()
const reg = loadRegistry(file)
let touched = false
for (const hostId of done) {
if (removeNode(reg, network, hostId).ok) touched = true
}
if (touched) saveRegistry(file, reg)
} catch (e) {
notes.push(`registry 收编失败:${msg(e)}`)
}
return { superseded: done, notes }
}
/**
* **确保该用户的实例凭据在实例 home 里可用**(S4 的投递 + S5 的续签,同一个入口)。
*
* 三种结果(`action`):
* | 情况 | 动作 | 触发者 |
* |---|---|---|
* | 文件不存在 | **首次签发**(平台生成密钥对) | 实例启动 |
* | 文件存在且剩余租约 ≤ TTL × {@link INSTANCE_GRANT_RENEW_FRACTION} | **续签**(同 hostId、同密钥) | 实例启动 / 定时器 |
* | 文件存在且租约仍充裕 | `unchanged`(**一次写都不做**) | 实例启动 / 定时器 |
*
* ⛔ 本函数**不抛异常**:全部失败都转成 `{ok:false, reason, detail}` —— 它的调用方在
* **实例启动路径**上,一个未捕获异常会把"起实例"变成"起不来"。启动路径的纪律是
* "拿不到凭据 = 少一条能力",⛔ **不是**"实例不给起"(那会把覆盖网络的分支性故障
* 升级成平台主功能故障)。
*/
export async function ensureInstanceCredential(
deps: InstanceCredentialDeps,
userId: string,
opts: { reason?: string; force?: boolean; nowMs?: number } = {},
): Promise<InstanceCredentialResult> {
const nowMs = opts.nowMs ?? Date.now()
const base = { userId }
let text: string | null
try {
text = await deps.userFs.readHomeFile(userId, INSTANCE_DEVICE_FILE)
} catch (e) {
return err(base, 'credential-file-unreadable', `读 ${INSTANCE_DEVICE_FILE} 失败:${msg(e)}`)
}
let existing: InstanceDeviceCredential | undefined
if (text !== null && text.trim() !== '') {
existing = parseInstanceCredential(text)
if (existing === undefined) {
return err(
base,
'credential-file-corrupt',
`${INSTANCE_DEVICE_FILE} 形状非法(⛔ 不猜、不重建:重建会换 hostId ⇒ 绕过针对旧 hostId 的停发并多占一个设备配额位)。处置 = 人工确认后删除该文件,下一次实例启动 / 续签扫描会重新签发`,
)
}
}
const ttl = ttlMs()
if (existing !== undefined && opts.force !== true) {
const remaining = Date.parse(existing.expiresAt) - nowMs
if (Number.isFinite(remaining) && remaining > ttl * INSTANCE_GRANT_RENEW_FRACTION) {
return okOf('unchanged', { userId, ...existing }, nowMs, false, [])
}
}
/* ── 密钥对:续签**读回文件里的那一把**(纪律 ④);首次签发才生成新的(纪律 ②)──
* ⚠️ 私钥在这里被"用一下",随后只存在于将要写出的文本里:⛔ 不进日志、不进返回值、
* 不进任何备份(`writeHomeFile` 不做备份,见纪律 ②)。
*/
let nodeKey: string
let nodePrivateKeyPem: string
let hmacSecret: string | undefined
if (existing !== undefined) {
nodeKey = existing.nodeKey
nodePrivateKeyPem = existing.nodePrivateKeyPem
hmacSecret = existing.hmacSecret
} else {
const keys = generateNodeKey()
nodeKey = keys.publicKey
nodePrivateKeyPem = keys.privateKeyPem
hmacSecret = undefined
}
const input: IssueDeviceGrantInput = {
db: deps.db,
userId,
nodeKey,
group: INSTANCE_DEVICE_GROUP,
kind: INSTANCE_DEVICE_KIND,
}
if (hmacSecret !== undefined) input.hmacSecret = hmacSecret
const issued = await issueDeviceGrant(input)
if (!issued.ok) {
return err(base, String(issued.body.error ?? 'issue-failed'), `签发被拒:${JSON.stringify(issued.body)}`, {
status: issued.status,
})
}
// 换 hostId 才算"换了一台设备" ⇒ 这时才需要收编旧的(续签不改 hostId,`superseded` 恒空)。
const superseded =
existing === undefined ? await supersedeOtherInstanceDevices(deps, userId, issued.hostId) : { superseded: [], notes: [] }
const next: InstanceDeviceCredential = {
version: INSTANCE_DEVICE_FILE_VERSION,
kind: INSTANCE_DEVICE_KIND,
network: issued.network,
hostId: issued.hostId,
nodeKey,
nodePrivateKeyPem,
hmacSecret: issued.hmacSecret,
grant: { doc: issued.grant.doc, sig: issued.grant.sig },
issuedAt: issued.ledger.grantIssuedAt,
expiresAt: issued.expiresAt,
renewals: issued.ledger.grantRenewals,
}
try {
await deps.userFs.writeHomeFile(userId, INSTANCE_DEVICE_FILE, serializeInstanceCredential(next))
} catch (e) {
return err(base, 'credential-write-failed', `写 ${INSTANCE_DEVICE_FILE} 失败:${msg(e)}`, {
hostId: issued.hostId,
})
}
const result = okOf(
existing === undefined ? 'issued' : 'renewed',
{ userId, ...next },
nowMs,
issued.applied.relayKeyRotated,
superseded.superseded,
)
// 非致命备注(收编失败等)一并带出:⛔ 不静默吞 —— "以为收编了、其实没"正是本线反复复发的那类。
return superseded.notes.length === 0 ? result : { ...result, detail: superseded.notes.join(';') }
}
/* ── S5 · 定时续签 ─────────────────────────────────────────────────────────── */
/** 一次续签扫描的读数 —— ⛔ 无密钥材料,可安全打印 / 进日志。 */
export interface InstanceCredentialRenewalReport {
/** 台账里扫到的 `kind='instance'` 且 `status='active'` 的条目数。 */
scanned: number
unchanged: number
renewed: number
issued: number
failed: Array<{ userId: string; reason: string; detail: string }>
/** 被收编的旧实例设备 hostId(纪律 ⑤;正常续签恒为空)。 */
superseded: string[]
}
export interface InstanceCredentialRenewalHandle {
/** 停表(`onClose` 用;⛔ 不 stop 会让进程带着一个 unref 定时器继续跑测试)。 */
stop: () => void
/** 手动跑一遍(测试 / 运维排查用;与定时器**互斥**,不会并发跑两遍)。 */
runOnce: () => Promise<InstanceCredentialRenewalReport>
/** 定时器是否真的挂上了(`intervalMs<=0` ⇒ `false`,只剩手动)。 */
armed: boolean
}
/**
* 起**平台侧定时续签**(S5)。
*
* 为什么续签必须有定时器:实例是**常驻**的(可能连跑数周),而设备租约默认 24 小时 ⇒
* 只靠"实例启动时检查"意味着"跑过一天就掉网"。
*
* 扫描源 = **台账**(`overlay_devices`),⛔ 不去读实例文件、⛔ 不依赖实例侧上报
* (§④ 决策 7:台账已存 `node_key`,续签**不需要**"实例 → 平台"的通道 —— 那条通道不存在)。
* ⚠️ 这意味着**新条目的产生**仍只有一条路:实例启动时的 `ensureInstanceCredential`
* (台账里有 `kind='instance'` 行 ⟺ 曾经签过)。⛔ 本扫描**不猜**"哪个用户该有实例"。
*
* ⚠️ 定时器 `unref()`:它**不该**让进程活着(与 `LocalSpawner` 的 idle-reap 同款;
* 否则关机会被它拖住)。
*/
export function startInstanceCredentialRenewal(
deps: InstanceCredentialDeps,
opts: { intervalMs?: number; onReport?: (report: InstanceCredentialRenewalReport) => void } = {},
): InstanceCredentialRenewalHandle {
const envRaw = process.env.DSHS_INSTANCE_GRANT_RENEW_MS
const intervalMs =
opts.intervalMs ?? (envRaw === undefined || envRaw.trim() === '' ? INSTANCE_GRANT_RENEW_MS_DEFAULT : Number(envRaw))
let running = false
const runOnce = async (): Promise<InstanceCredentialRenewalReport> => {
const report: InstanceCredentialRenewalReport = {
scanned: 0,
unchanged: 0,
renewed: 0,
issued: 0,
failed: [],
superseded: [],
}
if (running) return report // 上一遍还没跑完:本遍直接跳过(⛔ 不排队、不并发)
running = true
try {
let rows: OverlayDevice[]
try {
rows = await deps.db.listOverlayDevices()
} catch (e) {
report.failed.push({ userId: '-', reason: 'ledger-unreadable', detail: msg(e) })
return report
}
// ⛔ 只认租户网 `u:<userId>`:别的网名形态(`ops` 等)不猜归属 —— 实例凭据只属于某个人。
const users = [
...new Set(
rows
.filter((d) => d.kind === INSTANCE_DEVICE_KIND && d.status === 'active' && d.network.startsWith('u:'))
.map((d) => d.network.slice(2)),
),
]
report.scanned = users.length
for (const userId of users) {
const r = await ensureInstanceCredential(deps, userId, { reason: 'renewal-timer' })
if (!r.ok) {
report.failed.push({ userId, reason: r.reason, detail: r.detail })
continue
}
if (r.action === 'unchanged') report.unchanged += 1
else if (r.action === 'renewed') report.renewed += 1
else report.issued += 1
report.superseded.push(...r.superseded)
}
return report
} finally {
running = false
}
}
const armed = Number.isFinite(intervalMs) && intervalMs > 0
const timer = armed ? setInterval(() => void runOnce().then((r) => opts.onReport?.(r)), intervalMs) : undefined
timer?.unref()
return { stop: () => (timer === undefined ? undefined : clearInterval(timer)), runOnce, armed }
}