diff --git a/changelog.d/2128-health-endpoint.added.md b/changelog.d/2128-health-endpoint.added.md new file mode 100644 index 000000000..d48e6fcb8 --- /dev/null +++ b/changelog.d/2128-health-endpoint.added.md @@ -0,0 +1 @@ +The instance now answers `GET /api/health` without a session: a JSON object holding exactly `ok`, `version`, `commit` and `startedAt`, so a person or a monitor can ask which build is running instead of counting startup banners. It is served ahead of the sign-in wall as an exact route, keeps the wall's Host/Origin fence (loopback or a trusted authority only), answers `GET` only, and reports `commit: null` when the install has no git checkout of its own ([#2128](https://github.com/ThinkFlowLab/FlyBot/issues/2128)). diff --git a/core/index.js b/core/index.js index 37d3c9731..20a8f3e21 100644 --- a/core/index.js +++ b/core/index.js @@ -51,6 +51,7 @@ import * as hooks from './src/host/hooks.js' import * as remoteChat from './src/host/remote-chat.js' import * as workflowStore from './src/host/workflow-store.js' import * as channel from './src/host/channel.js' +import * as health from './src/host/health.js' export { COLORS } from './src/host/constants.js' @@ -61,7 +62,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] +const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/host/health.js b/core/src/host/health.js new file mode 100644 index 000000000..7907f4873 --- /dev/null +++ b/core/src/host/health.js @@ -0,0 +1,90 @@ +// GET /api/health (#2128, P-392): the one answer the instance gives without a session, so +// "does this process run the build I just published?" is asked of the service instead of +// counted from startup banners. It is an exact WebServer route — the web server matches an +// exact path before the /api prefix whose admission is the sign-in wall — and the four fields +// are the whole contract: no environment, path, host name or dependency version. +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const HEALTH_PATH = '/api/health' + +const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) +// The package root (`core/` in the repository, the installed directory otherwise), and the +// directory a clone puts it in. +const PKG_ROOT = dirname(fileURLToPath(new URL('../../package.json', import.meta.url))) + +/** + * HEAD of the clone this package sits in, or null for a package/path install. + * Only the one directory that holds the package is asked: a path install lives at + * `/node_modules/ds-bot`, and climbing to `` would report the user's own + * checkout as this package's commit, against the `commit: null` promise in core-host.md. + */ +export function readCommit(start = dirname(PKG_ROOT)) { + if (!existsSync(join(start, '.git'))) return null + try { + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: start, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() + return /^[0-9a-f]{40}$/.test(sha) ? sha : null + } catch { + // `git` may not exist on this machine, and a .git that is not a repository is no checkout. + return null + } +} + +/** The moment this process started, from its own uptime: stable while it runs, new after a restart. */ +export function startedAtOf(now = Date.now(), uptimeMs = process.uptime() * 1000) { + return new Date(now - uptimeMs).toISOString() +} + +/** Exactly the four fields, whatever the caller passes in. */ +export function healthPayload({ version = PKG.version, commit = readCommit(), startedAt = startedAtOf() } = {}) { + // JSON.stringify drops an undefined value silently, and a missing `version` key breaks the + // four-key contract the tests and the issue hold to, so a bad input answers a readable value. + const name = typeof version === 'string' ? version.trim() : '' + return { ok: true, version: name || 'unknown', commit, startedAt } +} + +export function install(rt) { + const { ctx } = rt + // The connection service owns the Host/Origin fence. Reading the decision from it keeps one + // implementation: the same loopback/bind/trusted-Host and Origin rules every /api path gets. + const connection = ctx.get?.('connection') + const attach = sub => { + if (sub?.webServer === undefined || connection === undefined) return + // Built here, not in install(): a composition with no web server registers no route and must + // not pay for a `git` call on behalf of one. + const payload = healthPayload() + const route = { + kind: 'exact', + path: HEALTH_PATH, + handler: (req, res) => { + // An exact route is matched before the /api prefix, so the wall's own admission never + // runs for it. Keep its first half: a request whose Host is not loopback, the bind + // address or a trusted authority (or whose Origin is foreign) is refused, exactly as + // the wall refuses it. Only the session half is skipped. The fence is read as a hard + // member of the service, the way `admit()` reads it: a connection that cannot answer + // throws here, and the web server answers that with 400, rather than let a request + // through without the fence. + if (connection.requestRejection(req) === 403) { + res.writeHead(403) + res.end('forbidden') + return + } + if (req.method !== 'GET') { + res.writeHead(405, { allow: 'GET' }) + res.end() + return + } + const body = JSON.stringify(payload) + res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }) + res.end(body) + }, + } + sub.effect(() => sub.webServer.register(route), 'ds-bot: /api/health route') + } + // No `webServer` outside the Web composition (Electron serves files over IPC): then the + // endpoint simply does not exist, and nothing else changes. + if (typeof ctx.inject === 'function') ctx.inject(['webServer'], attach) + else if (ctx.get?.('webServer') !== undefined) attach({ webServer: ctx.get('webServer'), effect: (execute, label) => ctx.effect(execute, label) }) +} diff --git a/core/test/health.test.js b/core/test/health.test.js new file mode 100644 index 000000000..2a1ba624a --- /dev/null +++ b/core/test/health.test.js @@ -0,0 +1,393 @@ +// GET /api/health (#2128, P-392) must answer without credentials: the instance cannot be +// checked from outside today, because the sign-in wall answers 401 for every /api request. +// The route is an exact WebServer route, which the web server matches before the /api prefix +// that owns the wall, and its whole contract is the four fields. +// +// The doubles below mirror the shipped host packages rather than a guess, and no dependency is +// added for them: `@deepseek-ai/dsh-host-webserver@0.2.1-rc.2` `lib/index.js` `:381` builds the +// path with `new URL(req.url ?? "/", "http://x").pathname` and `:494-504` matches the exact table +// before the longest prefix. The Host/Origin half belongs to the connection service: +// `@deepseek-ai/dsh-client-connection@0.2.1-rc.2` `lib/index.js` `:742-749` answers 403 for an +// untrusted authority and only then 401 for a missing session, `:777-779` is `isTrusted`, and +// `:862-865` / `:1049-1052` write those refusals (403 → `forbidden`, 401 → `unauthorized`). +import test from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { fakeContext } from './fake-ctx.js' +import { HEALTH_PATH, healthPayload, install, readCommit, startedAtOf } from '../src/host/health.js' + +const FIELDS = ['commit', 'ok', 'startedAt', 'version'] +const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) +const CORE_ROOT = fileURLToPath(new URL('..', import.meta.url)) +// What a local probe sends: the listener on loopback, with no browser markers. +const LOOPBACK = { host: '127.0.0.1:3080' } +const TRUSTED_HOSTS = ['monitor.example'] + +// The fence from isTrustedApiRequest(): the Host must be loopback, the bind address or one +// configured authority, and a cross-site marker or a foreign Origin is refused even then. +function isLoopbackName(name) { + const bare = name.replace(/^\[|\]$/g, '') + return bare === 'localhost' || bare === '::1' || /^127(?:\.\d{1,3}){3}$/.test(bare) +} + +function isTrusted(req, { trustedHosts = TRUSTED_HOSTS, bindHost = '127.0.0.1' } = {}) { + const host = req.headers.host + if (host === undefined) return false + let hostUrl + try { + hostUrl = new URL(`http://${host}`) + } catch { + return false + } + if (!isLoopbackName(hostUrl.hostname) && !trustedHosts.includes(hostUrl.hostname) && hostUrl.hostname !== bindHost) return false + if (req.headers['sec-fetch-site'] === 'cross-site') return false + const origin = req.headers.origin + if (origin === undefined) return true + try { + return new URL(origin).host === host + } catch { + return false + } +} + +// The two tables @deepseek-ai/dsh-host-webserver keeps: an exact path wins over a prefix, and +// among prefixes the longest wins. Registration duplicates throw, as they do there. +function webServer() { + const exact = new Map() + const prefixes = new Map() + return { + exact, + prefixes, + register(route) { + const table = route.kind === 'exact' ? exact : prefixes + if (table.has(route.path)) throw new Error(`webserver: route ${route.kind} ${route.path} is already registered`) + table.set(route.path, route) + return () => table.delete(route.path) + }, + } +} + +// The connection service the route reads its fence from: 403 for an untrusted authority, then +// 401 for a request with no session — the order admit() uses. +function connection(options = {}) { + return { requestRejection: req => (isTrusted(req, options) ? 401 : 403) } +} + +// The sign-in wall the connection plugin mounts as the /api prefix: the fence first, then a +// browser session before any handler sees it. +function signInWall(options = {}) { + return { + kind: 'prefix', + path: '/api', + handler: (req, res) => { + if (!isTrusted(req, options)) { + res.writeHead(403) + res.end('forbidden') + return + } + if (req.headers.cookie === undefined) { + res.writeHead(401) + res.end('unauthorized') + return + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ signedIn: true })) + }, + } +} + +async function serve(route, { method = 'GET', headers = LOOPBACK, path = HEALTH_PATH } = {}) { + const res = { + status: undefined, + headers: {}, + chunks: [], + writeHead(status, next = {}) { + this.status = status + for (const [name, value] of Object.entries(next)) this.headers[name.toLowerCase()] = value + }, + end(chunk) { + if (chunk !== undefined && chunk !== null) this.chunks.push(Buffer.from(chunk)) + }, + } + await route.handler({ method, url: path, headers }, res) + return { status: res.status, headers: res.headers, body: Buffer.concat(res.chunks).toString('utf8') } +} + +// One request through the same order the web server uses, so a test proves where the route sits. +// The path is reduced the way the server reduces it (`new URL(...).pathname`), query and all. +async function request(server, path, { method = 'GET', headers = LOOPBACK } = {}) { + const pathname = new URL(path, 'http://x').pathname + const exact = server.exact.get(pathname) + if (exact !== undefined) return serve(exact, { method, headers, path }) + let best + for (const route of server.prefixes.values()) { + if (pathname === route.path || pathname.startsWith(`${route.path}/`)) { + if (best === undefined || route.path.length > best.path.length) best = route + } + } + if (best === undefined) return { status: 404, headers: {}, body: 'not found' } + return serve(best, { method, headers, path }) +} + +// The plugin context cordis gives install(): an injected service scope, or the get() fallback. +function boot({ server, fence = connection(), inject = server !== undefined } = {}) { + const services = {} + if (server !== undefined) { + services.webServer = server + services.connection = fence + } + const ctx = fakeContext({ services }) + if (inject) { + ctx.inject = (names, attach) => { + assert.deepEqual(names, ['webServer']) + attach({ webServer: server, effect: (execute, label) => ctx.effect(execute, label) }) + } + } + const rt = { ctx } + install(rt) + return { ctx, rt } +} + +async function health(server, options = {}) { + const { status, headers, body } = await request(server, HEALTH_PATH, options) + return { status, headers, body, json: status === 200 ? JSON.parse(body) : undefined } +} + +test('health: GET /api/health answers 200 with exactly four fields, ahead of the sign-in wall', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + + const { status, headers, json } = await health(server) + assert.equal(status, 200) + assert.match(headers['content-type'], /application\/json/) + assert.deepEqual(Object.keys(json).sort(), FIELDS, 'a fifth field must fail this test') + assert.equal(json.ok, true) + assert.equal(json.version, PKG.version) + assert.ok(typeof json.version === 'string' && json.version.length > 0, 'version is a non-empty string') + assert.ok(json.commit === null || /^[0-9a-f]{40}$/.test(json.commit), `commit is a 40-hex sha or null, not ${json.commit}`) + assert.equal(new Date(json.startedAt).toISOString(), json.startedAt, 'startedAt is an ISO timestamp') +}) + +test('health: the answer is the same while authz is enforced, and needs no credentials', async () => { + const server = webServer() + server.register(signInWall()) + const { rt } = boot({ server }) + rt.authzEnforced = () => true + + const first = await health(server) + const second = await health(server) + assert.equal(first.status, 200) + assert.equal(second.status, 200) + assert.deepEqual(Object.keys(first.json).sort(), FIELDS) + assert.deepEqual(second.json, first.json, 'two requests in one process agree') +}) + +test('health: the rest of /api still meets the wall, and a disposed route gives it back', async () => { + const server = webServer() + server.register(signInWall()) + const { ctx } = boot({ server }) + + for (const path of ['/api/state', '/api/bot', '/api/health/extra']) { + const { status, body } = await request(server, path) + assert.equal(status, 401, `${path} is still behind the wall`) + assert.equal(body, 'unauthorized') + } + assert.equal((await request(server, '/')).status, 404) + + await ctx.dispose() + assert.equal((await request(server, HEALTH_PATH)).status, 401, 'the route went away with the plugin') +}) + +test('health: the Host/Origin fence decides before either the answer or the method', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + + const foreign = await health(server, { headers: { host: 'rebind.example:3080' } }) + assert.equal(foreign.status, 403, 'a Host that is neither loopback, the bind address nor trusted is refused') + assert.equal(foreign.body, 'forbidden') + assert.equal((await request(server, '/api/state', { headers: { host: 'rebind.example:3080' } })).status, 403, 'and the wall refuses it the same way') + + const crossSite = await health(server, { headers: { ...LOOPBACK, 'sec-fetch-site': 'cross-site' } }) + assert.equal(crossSite.status, 403, 'a cross-site marker is refused even from loopback') + const foreignOrigin = await health(server, { headers: { ...LOOPBACK, origin: 'http://rebind.example' } }) + assert.equal(foreignOrigin.status, 403, 'an Origin that is not this authority is refused') + const sameOrigin = await health(server, { headers: { ...LOOPBACK, origin: 'http://127.0.0.1:3080' } }) + assert.equal(sameOrigin.status, 200, 'a same-origin loopback request still answers') + + const noHost = await health(server, { headers: {} }) + assert.equal(noHost.status, 403, 'a request without a Host is not trusted') + + const trusted = await health(server, { headers: { host: 'monitor.example:3080' } }) + assert.equal(trusted.status, 200, 'a configured authority is served without a session, as elsewhere') + assert.deepEqual(Object.keys(trusted.json).sort(), FIELDS) + assert.equal((await request(server, '/api/state', { headers: { host: 'monitor.example:3080' } })).status, 401, 'but the rest of /api still wants a session') + + const headFromForeign = await request(server, HEALTH_PATH, { method: 'HEAD', headers: { host: 'rebind.example:3080' } }) + assert.equal(headFromForeign.status, 403, 'the fence runs before the method is read') +}) + +// The web server's own answer to a handler that throws: `handle(...).catch` logs and, while no +// header went out, writes 400 (`@deepseek-ai/dsh-host-webserver@0.2.1-rc.2` `lib/index.js:248-256`). +// `serve()` calls a handler directly, so this repeats that arm: a route that throws must reach it. +async function serveThroughServer(server, path, { method = 'GET', headers = LOOPBACK } = {}) { + const res = { + status: undefined, + chunks: [], + writeHead(status) { + this.status = status + }, + end(chunk) { + if (chunk !== undefined && chunk !== null) this.chunks.push(Buffer.from(chunk)) + }, + } + try { + await server.exact.get(new URL(path, 'http://x').pathname).handler({ method, url: path, headers }, res) + } catch { + if (res.status === undefined) { + res.status = 400 + res.end() + } + } + return { status: res.status, body: Buffer.concat(res.chunks).toString('utf8') } +} + +// The fence capability is not optional: a composition that hands the plugin a connection without +// `requestRejection` must not get a health answer, because the answer would have skipped the +// Host/Origin fence every other /api path meets. `admit()` reads the same call as a hard member, +// so a missing one is a server error — 400 from the arm above — and never a 200 (#2129, Security). +test('health: a connection without the fence capability answers 400, not a health answer', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server, fence: {} }) + + const { status, body } = await serveThroughServer(server, HEALTH_PATH, { headers: { host: 'rebind.example:3080' } }) + assert.equal(status, 400, 'a missing fence capability is a server error, not a request that passed the fence') + for (const field of FIELDS) assert.doesNotMatch(body, new RegExp(`"${field}"`), `no ${field} leaves without the fence's answer`) + + const loopback = await serveThroughServer(server, HEALTH_PATH) + assert.equal(loopback.status, 400, 'and a request that would have been trusted is refused the same way') +}) + +test('health: a method other than GET is not served as health', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + const { status, headers } = await health(server, { method: 'POST' }) + assert.equal(status, 405) + assert.equal(headers.allow, 'GET') +}) + +test('health: HEAD and OPTIONS get 405 with allow: GET and no body', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + + for (const method of ['HEAD', 'OPTIONS']) { + const { status, headers, body } = await request(server, HEALTH_PATH, { method }) + assert.equal(status, 405, `${method} is not GET`) + assert.equal(headers.allow, 'GET') + assert.equal(body, '', `${method} carries no payload`) + } +}) + +test('health: a query string does not change the route or the answer', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + + const plain = await health(server) + const queried = await request(server, `${HEALTH_PATH}?x=1`) + assert.equal(queried.status, 200, 'the pathname decides, not the query') + assert.deepEqual(Object.keys(JSON.parse(queried.body)).sort(), FIELDS) + assert.deepEqual(JSON.parse(queried.body), plain.json) +}) + +test('health: the payload keeps four fields and a non-empty version whatever it is handed', () => { + const fromOptions = healthPayload({ version: '9.9.9', commit: null, startedAt: '2026-01-01T00:00:00.000Z', env: 'production', host: 'box' }) + assert.deepEqual(Object.keys(fromOptions).sort(), FIELDS) + assert.equal(fromOptions.version, '9.9.9') + assert.deepEqual(Object.keys(healthPayload()).sort(), FIELDS) + for (const version of ['', ' ', undefined, null, 42]) { + const payload = healthPayload({ version, commit: null, startedAt: '2026-01-01T00:00:00.000Z' }) + assert.deepEqual(Object.keys(payload).sort(), FIELDS, `version ${JSON.stringify(version)} must not drop the key`) + assert.ok(typeof payload.version === 'string' && payload.version.trim().length > 0, `version ${JSON.stringify(version)} became ${JSON.stringify(payload.version)}`) + } + assert.throws(() => assert.deepEqual(Object.keys({ ...healthPayload(), host: 'box' }).sort(), FIELDS), /Expected values to be strictly deep-equal|host/) +}) + +test('health: startedAt is the process start, stable in one process and new in the next', async () => { + const now = Date.now() + const early = startedAtOf(now, 60_000) + const late = startedAtOf(now, 1_000) + assert.notEqual(early, late) + assert.equal(Date.parse(early), now - 60_000) + + const server = webServer() + server.register(signInWall()) + boot({ server }) + const a = (await health(server)).json.startedAt + const b = (await health(server)).json.startedAt + assert.equal(a, b) + assert.ok(Math.abs(Date.now() - Date.parse(a)) < 10 * 60_000, 'the process started minutes ago, not at request time') +}) + +test('health: commit is the checkout HEAD inside a git clone and null without one', t => { + const repoRoot = dirname(CORE_ROOT) + let expected + try { + expected = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim() + } catch { + expected = undefined + } + const home = mkdtempSync(join(tmpdir(), 'ds-bot-health-')) + try { + assert.equal(readCommit(home), null, 'no .git above the module means no commit') + if (expected === undefined) { + t.diagnostic('git is not available here; the checkout half of this test is skipped') + return + } + assert.equal(readCommit(repoRoot), expected) + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) + +test("health: commit is null when the package sits in someone else's checkout", t => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-health-')) + try { + const project = join(home, 'project') + const nodeModules = join(project, 'node_modules') + const installed = join(nodeModules, 'ds-bot') + mkdirSync(installed, { recursive: true }) + writeFileSync(join(installed, 'package.json'), '{"name":"ds-bot","version":"0.0.0"}\n') + let head + try { + execFileSync('git', ['init', '-q', project]) + execFileSync('git', ['-C', project, 'config', 'user.email', 'dev1@flybot.local']) + execFileSync('git', ['-C', project, 'config', 'user.name', 'Dev-1']) + writeFileSync(join(project, 'README.md'), "the user's project\n") + execFileSync('git', ['-C', project, 'add', '.']) + execFileSync('git', ['-C', project, 'commit', '-qm', 'project']) + head = execFileSync('git', ['-C', project, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() + } catch { + t.diagnostic('git is not available here; the nested-checkout half is skipped') + return + } + assert.equal(readCommit(nodeModules), null, `the project's HEAD ${head} at the layer above node_modules is not this package's commit`) + assert.equal(readCommit(project), head, 'the directory a checkout put core/ in is still read') + assert.equal(readCommit(installed), null, 'the package directory itself is not a checkout') + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) + +test('health: install without a web server registers nothing and does not throw', () => { + const { ctx } = boot({ server: undefined }) + assert.equal(ctx.calls.filter(call => call.startsWith('effect')).length, 0) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index ca78a8042..a3f1841b8 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -40,6 +40,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/` | Host-side helpers (non-hooks). | | `core/src/host/text.js` | The pure text helpers the host modules share: message text, names, model refs, cutting and estimating text, and the clock format of chat anchors. Since #1895 `codePoints(text)` (a step per code point, no array built since #1898) is how the host counts a limit — one character costs one whatever its UTF-16 width — and the bounds this change puts through it are: a memory entry of 300 and a `MEMORY.md` of 4,000 or a topic of 16,000 (the refusal and the size note print the code-point number), a scheduled task's name of 120, a message of 20,000, and the Project memory entry of 600 in both writers, so an emoji is never charged twice. Both cuts land on a code point boundary: `clip` and `excerpt` move back rather than keep half of a surrogate pair, so a clipped emoji reaches a UTF-8 encoder as the character and not as U+FFFD ([#1907](https://github.com/ThinkFlowLab/FlyBot/issues/1907)). Since #1912 that rule is one exported helper rather than a private detail: `cut(text, max)` answers `{ text, cut }` with the longest prefix that stops before a split pair, and the archive writers that used to `slice(0, max)` go through it — the `oneLine` of `decisions.js` and `meetings.js` (into `decisions.jsonl` and `decisions/-.md`), `claimLine` in `groups.js`, the report cap in `diagnostics.js`, `cleanLabel` in `flybot-feed.js` and `clean` in `browser.js` — so a decision or a meeting record holds the whole character instead of U+FFFD. Since #1916 the boundary rule itself lives in `core/src/shared/text-boundary.js` (`codePointStart`, `codePointEnd`, `cut`), and `host/text.js` builds its `cut` on it so a shared module can bound text too; the eight remaining `slice(0, max)` writers follow it — the claim cue's fallback reference (60), the task lead's name in a member prompt (80), a failed group Session's error text (300), `read_own_chat`'s `query` (80) and `around` (40), the open-page address and title (2000/300), the diagnostics browser-log line and its code (500/40), and `cleanPeerName` in `shared/remote-label.js` (60) — each keeping the longest prefix that stops before a split pair, `length <= max` and a byte prefix of the input. Since #1918 the memory review's own two bounds go through the same rule: its 800-unit context `clip` cuts through `cut`, and its 3,000-unit two-ended `ends` takes the head through `cut` and moves the tail's start back with `codePointStart`, giving the head back the unit a stepped-back tail keeps, so the prompt and the log hold whole characters within the same budget; the exported `clip`'s reason is optional there too, so a two-argument call reads `… (cut)` instead of `… (cut; undefined)` while a call with a reason is unchanged. Since #1923 the four bounds whose text crosses an owner boundary go through the same `cut` — `cleanPublishedName` in `hooks.js` (60, into `state.flybot.remote`), the group member line's `displayName` 60 and `ownerName` 40 in `tools.js`, `label` in `reach.js` (60, the `othersInRoom` labels), and the local `clip` in `shared/room-share.js` (its per-field caps included) — so a published, listed or relayed name keeps whole characters and never reaches a UTF-8 encoder as U+FFFD ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)). The same rule now covers the host's own line bounds: the busy ack's 600-unit task and 1,500-unit message text in `busy-ack.js`, the group line's notice (100), a Bot's job line (120), the group answer echo (300) and the rework line's note (200) in `tools.js`, and `cleanName`'s 60-unit peer name in `delivery.js`, which lands in `fenceUntrusted`'s `from="…"` attribute — so none of them can end in half a surrogate pair. The five bounds `turns.js` keeps go through `cut` as well: a failure's 500-unit message and the 200-unit message of its line (`turnFailure`, `failureLine`), the 79-unit step a running call shows (the `…` is appended only when `cut` dropped something, never by a length guess), an approval card's 2,000-unit description and 4,000-unit command (the card stores the command "Allow once" runs rather than a preview of it, and one that still had to be cut ends with `…` inside that bound, so the card says what it abridged and never drops the fact, [#1992](https://github.com/ThinkFlowLab/FlyBot/issues/1992)), and the 160-unit reason a failed turn leaves for the inbox. Since #1963 `cut` also flattens its input before it bounds — `String(text).toWellFormed()` first, `codePointEnd` on the flat string — so a title, a note or a name that arrives already carrying an unpaired surrogate is stored as U+FFFD instead of the lone surrogate, and U+FFFD is one UTF-16 unit for the one it replaces, so a well-formed input keeps its exact prefix and its `cut` flag ([#1963](https://github.com/ThinkFlowLab/FlyBot/issues/1963)). Since #1979 the six remaining bounds on text another home or another person supplied go through it too — a connector item name (80, `connectors.js`), a role label another home set on a face-room seat (80, `face-lead.js`), a cross-home group name (200, `project-team.js`), a capability description from a peer server (2,000, `capabilities.js`), the remote home's own error text (157, `remote-chat.js`) and an abbreviated remote reply (80, same file) — so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, while `remote-chat.js`'s `…` and trailing-period strip still apply to the cut value ([#1979](https://github.com/ThinkFlowLab/FlyBot/issues/1979)). The two bounds on a friend's Bot's own name in that file — the name its chat is created with (40) and the name a refresh writes (40) — go through `cut` as well, so a refresh cannot store a lone high surrogate and a create no longer hands `roster.createBot` half a pair to flatten into U+FFFD; the budget stays 40 and a shorter name is stored word for word. Since #1993 the last five bounds in `core/src/host/` go through it too — the quoted first line of a checkpoint's kept tail (80, `steward.js`), an approval record's `reason` (500, `approvals.js`), a mention receipt's reason line (240, `hooks.js`), the title given to a DSH Agent's Session (80, `AGENT_SESSION_TITLE_MAX` in `agents.js`) and a schedule run's failure `reason` (200, `schedules.js`) — so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, and none of the five maxima changes ([#1993](https://github.com/ThinkFlowLab/FlyBot/issues/1993)). Since #2002 the connector's own error text goes through it too: the `reason` in `core/src/host/connectors.js` — the text an MCP server's failure leaves on the owner's connector card — keeps the longest prefix that stops before a split pair, so it is well formed and a code-point prefix of what the server sent; since #2016 that prefix is 2,000 units rather than 300, and one that had to be cut ends with `…` inside the bound, so the card says what it abridged ([#2002](https://github.com/ThinkFlowLab/FlyBot/issues/2002), [#2016](https://github.com/ThinkFlowLab/FlyBot/issues/2016)). Since #2005 the five bounds the Project panels read go through it as well — the assistant card's 77-unit title in `project-assistant.js`, the board's 200-unit one-line value and a decision title's 200 in `project-board.js`, and `capped`'s name, role, persona, model and color caps plus the dossier's 4,000-unit memory head in `project-team.js`, whose head is bounded where `memoryDigest` reads it (`memory.js`), because a cut at the dossier alone could only turn half a pair into U+FFFD and never drop the character whole — so a dossier row or a board card keeps whole characters ([#2005](https://github.com/ThinkFlowLab/FlyBot/issues/2005)). Since [#2321](https://github.com/ThinkFlowLab/FlyBot/issues/2321) a bound also judges in code points, not only counts in them: `fits(text, max)` in `core/src/shared/text-boundary.js` is the one predicate — a character outside the BMP spends one of the budget and not two — and `clip` and `excerpt` judge with it while their cuts keep going through `codePointEnd`/`cut`, so a 300-character text is no longer cut by a 400-character limit on its way to `read_own_chat` and the search rows. Since [#2339](https://github.com/ThinkFlowLab/FlyBot/issues/2339) the two bounds in `core/src/host/` that still judged with `text.length` use it as well — the memory review's 3,000-unit two-ended `ends` in `memory.js` and `room-context.js`'s `oneLine` (40/80/24/60) — so a user line or a board line of characters outside the BMP stays whole inside its own limit instead of being cut at half of it; both maxima and every `cut` call are unchanged, so an ASCII text judges exactly as `length <= max` did. The memory review's own third `clip` is the exported one now, so its cut reads `… (cut)`, and a bound added later that judges with `text.length` fails `core/test/text-bounds.test.js` at its own `file:line`. | | `core/src/host/prompt.js` | The Bot system prompt; a Main Bot's section is headed `You are the Main Bot` and uses that one term, the one the UI shows (#276). The team rules state the idle default (#699): a Bot does nothing until the user addresses it, it is @-mentioned in a group, its parent gives it a task, or unfinished tasks it was already given are still on its board. It does not invent work. A coordinator acts on a user task rather than on its own initiative — an addressed Bot answers as before. | +| `core/src/host/health.js` | `GET /api/health` (#2128, P-392): the one answer this instance gives without a session, and exactly four fields — `ok` (always `true`), `version` (`core/package.json`), `commit` (`git rev-parse HEAD` of the clone `core/` sits in, `null` for a package or path install with no `.git`) and `startedAt` (this process's start, from `process.uptime()`, stable until a restart). It is an **exact** WebServer route, which the web server matches before the `/api` prefix whose admission is the sign-in wall, so no cookie is needed and no other `/api` route changes; outside the Web composition there is no `webServer` and the route does not exist. A method other than `GET` answers 405. Tests: `core/test/health.test.js`. | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). `rt.reach` also reads the room a send already belongs to when the caller names none (#948): `sharedRoom` applies only to the `roomOnly` identity — the Bot holds no grant at all and came into that **face-to-face** room by its number (`isCodeJoined`) — and looks for the one such room where the **exact** target is still seated; a Bot that holds a grant, or a room of this home, is judged as it always was and keeps asking for an explicit `roomId`, so a Bot that joined a room by number — a `roomOnly` identity with no person-grant — may line that seat, which is the same allowance `reach()` already applies to an explicit `roomId`; the room itself is named outbound by the transport, never assumed here. A `user-mention` with no named room is also judged under `mentionRoom` (#1126): one live local or inbound group where that Bot is a member and the exact target is still a remote seat, including a seat a group invite admitted (`admittedBy: 'ticket'`). A plain line, `message_bot`, a seat that has left, and a stranger outside every such room stay on the grant rule. Face-to-face stays on `sharedRoom`. Tests: `core/test/reach-policy.test.js`, `core/test/flybot.test.js`. | @@ -74,6 +75,22 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core Add a new invariant under the heading it belongs to (or a new heading), not at the end of the page: parallel PRs then do not edit the same lines. +### The health answer is four fields, and unauthenticated on purpose (#2128) + +`GET /api/health` answers `200` with `ok`, `version`, `commit` and `startedAt`, and nothing else: the +key set is the contract, so a fifth field fails a test instead of shipping, and no environment, path, +host name, dependency version or count may be added to it. It is reachable before the sign-in wall +because it is an **exact** route and the wall is the `/api` **prefix** — no cookie and no session — +but it keeps the wall's Host/Origin half: a request whose `Host` is neither loopback, the bind +address nor a configured trusted authority, or whose `Origin` or `sec-fetch-site` is foreign, gets +`403 forbidden` exactly as every other `/api` path does, and the route reads that decision from the +connection service instead of copying the fence. Otherwise it changes nothing about how every other +`/api` path is admitted. It answers only `GET` (405 with `allow: GET` otherwise) and never reads +`authzEnforced()`, so turning enforcement on does not close it. The answer is fixed per process: +`startedAt` and `commit` cannot move until a restart, and an install that cannot name a checkout — a +path install under someone else's repository included — answers `commit: null` rather than guessing. + + ### A workflow is data (#1272) A workflow definition — node ids, the role on each node, the typed artifacts each one takes in and diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index c812dc7e5..eb317cc7a 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -33,7 +33,7 @@ diff --git a/index.js b/index.js import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' import * as approvals from './src/host/approvals.js' -@@ -24,11 +24,19 @@ import * as images from './src/host/images.js' +@@ -24,12 +24,21 @@ import * as images from './src/host/images.js' import * as browser from './src/host/browser.js' import * as secrets from './src/host/secrets.js' import * as connectors from './src/host/connectors.js' @@ -51,14 +51,16 @@ diff --git a/index.js b/index.js +import * as remoteChat from './src/host/remote-chat.js' +import * as workflowStore from './src/host/workflow-store.js' import * as channel from './src/host/channel.js' ++import * as health from './src/host/health.js' export { COLORS } from './src/host/constants.js' + @@ -40,10 +40,30 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] ++const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) diff --git a/patches/ds-bot/files/src/host/health.js b/patches/ds-bot/files/src/host/health.js new file mode 100644 index 000000000..7907f4873 --- /dev/null +++ b/patches/ds-bot/files/src/host/health.js @@ -0,0 +1,90 @@ +// GET /api/health (#2128, P-392): the one answer the instance gives without a session, so +// "does this process run the build I just published?" is asked of the service instead of +// counted from startup banners. It is an exact WebServer route — the web server matches an +// exact path before the /api prefix whose admission is the sign-in wall — and the four fields +// are the whole contract: no environment, path, host name or dependency version. +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const HEALTH_PATH = '/api/health' + +const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) +// The package root (`core/` in the repository, the installed directory otherwise), and the +// directory a clone puts it in. +const PKG_ROOT = dirname(fileURLToPath(new URL('../../package.json', import.meta.url))) + +/** + * HEAD of the clone this package sits in, or null for a package/path install. + * Only the one directory that holds the package is asked: a path install lives at + * `/node_modules/ds-bot`, and climbing to `` would report the user's own + * checkout as this package's commit, against the `commit: null` promise in core-host.md. + */ +export function readCommit(start = dirname(PKG_ROOT)) { + if (!existsSync(join(start, '.git'))) return null + try { + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: start, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() + return /^[0-9a-f]{40}$/.test(sha) ? sha : null + } catch { + // `git` may not exist on this machine, and a .git that is not a repository is no checkout. + return null + } +} + +/** The moment this process started, from its own uptime: stable while it runs, new after a restart. */ +export function startedAtOf(now = Date.now(), uptimeMs = process.uptime() * 1000) { + return new Date(now - uptimeMs).toISOString() +} + +/** Exactly the four fields, whatever the caller passes in. */ +export function healthPayload({ version = PKG.version, commit = readCommit(), startedAt = startedAtOf() } = {}) { + // JSON.stringify drops an undefined value silently, and a missing `version` key breaks the + // four-key contract the tests and the issue hold to, so a bad input answers a readable value. + const name = typeof version === 'string' ? version.trim() : '' + return { ok: true, version: name || 'unknown', commit, startedAt } +} + +export function install(rt) { + const { ctx } = rt + // The connection service owns the Host/Origin fence. Reading the decision from it keeps one + // implementation: the same loopback/bind/trusted-Host and Origin rules every /api path gets. + const connection = ctx.get?.('connection') + const attach = sub => { + if (sub?.webServer === undefined || connection === undefined) return + // Built here, not in install(): a composition with no web server registers no route and must + // not pay for a `git` call on behalf of one. + const payload = healthPayload() + const route = { + kind: 'exact', + path: HEALTH_PATH, + handler: (req, res) => { + // An exact route is matched before the /api prefix, so the wall's own admission never + // runs for it. Keep its first half: a request whose Host is not loopback, the bind + // address or a trusted authority (or whose Origin is foreign) is refused, exactly as + // the wall refuses it. Only the session half is skipped. The fence is read as a hard + // member of the service, the way `admit()` reads it: a connection that cannot answer + // throws here, and the web server answers that with 400, rather than let a request + // through without the fence. + if (connection.requestRejection(req) === 403) { + res.writeHead(403) + res.end('forbidden') + return + } + if (req.method !== 'GET') { + res.writeHead(405, { allow: 'GET' }) + res.end() + return + } + const body = JSON.stringify(payload) + res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }) + res.end(body) + }, + } + sub.effect(() => sub.webServer.register(route), 'ds-bot: /api/health route') + } + // No `webServer` outside the Web composition (Electron serves files over IPC): then the + // endpoint simply does not exist, and nothing else changes. + if (typeof ctx.inject === 'function') ctx.inject(['webServer'], attach) + else if (ctx.get?.('webServer') !== undefined) attach({ webServer: ctx.get('webServer'), effect: (execute, label) => ctx.effect(execute, label) }) +} diff --git a/patches/ds-bot/files/test/health.test.js b/patches/ds-bot/files/test/health.test.js new file mode 100644 index 000000000..2a1ba624a --- /dev/null +++ b/patches/ds-bot/files/test/health.test.js @@ -0,0 +1,393 @@ +// GET /api/health (#2128, P-392) must answer without credentials: the instance cannot be +// checked from outside today, because the sign-in wall answers 401 for every /api request. +// The route is an exact WebServer route, which the web server matches before the /api prefix +// that owns the wall, and its whole contract is the four fields. +// +// The doubles below mirror the shipped host packages rather than a guess, and no dependency is +// added for them: `@deepseek-ai/dsh-host-webserver@0.2.1-rc.2` `lib/index.js` `:381` builds the +// path with `new URL(req.url ?? "/", "http://x").pathname` and `:494-504` matches the exact table +// before the longest prefix. The Host/Origin half belongs to the connection service: +// `@deepseek-ai/dsh-client-connection@0.2.1-rc.2` `lib/index.js` `:742-749` answers 403 for an +// untrusted authority and only then 401 for a missing session, `:777-779` is `isTrusted`, and +// `:862-865` / `:1049-1052` write those refusals (403 → `forbidden`, 401 → `unauthorized`). +import test from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { fakeContext } from './fake-ctx.js' +import { HEALTH_PATH, healthPayload, install, readCommit, startedAtOf } from '../src/host/health.js' + +const FIELDS = ['commit', 'ok', 'startedAt', 'version'] +const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) +const CORE_ROOT = fileURLToPath(new URL('..', import.meta.url)) +// What a local probe sends: the listener on loopback, with no browser markers. +const LOOPBACK = { host: '127.0.0.1:3080' } +const TRUSTED_HOSTS = ['monitor.example'] + +// The fence from isTrustedApiRequest(): the Host must be loopback, the bind address or one +// configured authority, and a cross-site marker or a foreign Origin is refused even then. +function isLoopbackName(name) { + const bare = name.replace(/^\[|\]$/g, '') + return bare === 'localhost' || bare === '::1' || /^127(?:\.\d{1,3}){3}$/.test(bare) +} + +function isTrusted(req, { trustedHosts = TRUSTED_HOSTS, bindHost = '127.0.0.1' } = {}) { + const host = req.headers.host + if (host === undefined) return false + let hostUrl + try { + hostUrl = new URL(`http://${host}`) + } catch { + return false + } + if (!isLoopbackName(hostUrl.hostname) && !trustedHosts.includes(hostUrl.hostname) && hostUrl.hostname !== bindHost) return false + if (req.headers['sec-fetch-site'] === 'cross-site') return false + const origin = req.headers.origin + if (origin === undefined) return true + try { + return new URL(origin).host === host + } catch { + return false + } +} + +// The two tables @deepseek-ai/dsh-host-webserver keeps: an exact path wins over a prefix, and +// among prefixes the longest wins. Registration duplicates throw, as they do there. +function webServer() { + const exact = new Map() + const prefixes = new Map() + return { + exact, + prefixes, + register(route) { + const table = route.kind === 'exact' ? exact : prefixes + if (table.has(route.path)) throw new Error(`webserver: route ${route.kind} ${route.path} is already registered`) + table.set(route.path, route) + return () => table.delete(route.path) + }, + } +} + +// The connection service the route reads its fence from: 403 for an untrusted authority, then +// 401 for a request with no session — the order admit() uses. +function connection(options = {}) { + return { requestRejection: req => (isTrusted(req, options) ? 401 : 403) } +} + +// The sign-in wall the connection plugin mounts as the /api prefix: the fence first, then a +// browser session before any handler sees it. +function signInWall(options = {}) { + return { + kind: 'prefix', + path: '/api', + handler: (req, res) => { + if (!isTrusted(req, options)) { + res.writeHead(403) + res.end('forbidden') + return + } + if (req.headers.cookie === undefined) { + res.writeHead(401) + res.end('unauthorized') + return + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ signedIn: true })) + }, + } +} + +async function serve(route, { method = 'GET', headers = LOOPBACK, path = HEALTH_PATH } = {}) { + const res = { + status: undefined, + headers: {}, + chunks: [], + writeHead(status, next = {}) { + this.status = status + for (const [name, value] of Object.entries(next)) this.headers[name.toLowerCase()] = value + }, + end(chunk) { + if (chunk !== undefined && chunk !== null) this.chunks.push(Buffer.from(chunk)) + }, + } + await route.handler({ method, url: path, headers }, res) + return { status: res.status, headers: res.headers, body: Buffer.concat(res.chunks).toString('utf8') } +} + +// One request through the same order the web server uses, so a test proves where the route sits. +// The path is reduced the way the server reduces it (`new URL(...).pathname`), query and all. +async function request(server, path, { method = 'GET', headers = LOOPBACK } = {}) { + const pathname = new URL(path, 'http://x').pathname + const exact = server.exact.get(pathname) + if (exact !== undefined) return serve(exact, { method, headers, path }) + let best + for (const route of server.prefixes.values()) { + if (pathname === route.path || pathname.startsWith(`${route.path}/`)) { + if (best === undefined || route.path.length > best.path.length) best = route + } + } + if (best === undefined) return { status: 404, headers: {}, body: 'not found' } + return serve(best, { method, headers, path }) +} + +// The plugin context cordis gives install(): an injected service scope, or the get() fallback. +function boot({ server, fence = connection(), inject = server !== undefined } = {}) { + const services = {} + if (server !== undefined) { + services.webServer = server + services.connection = fence + } + const ctx = fakeContext({ services }) + if (inject) { + ctx.inject = (names, attach) => { + assert.deepEqual(names, ['webServer']) + attach({ webServer: server, effect: (execute, label) => ctx.effect(execute, label) }) + } + } + const rt = { ctx } + install(rt) + return { ctx, rt } +} + +async function health(server, options = {}) { + const { status, headers, body } = await request(server, HEALTH_PATH, options) + return { status, headers, body, json: status === 200 ? JSON.parse(body) : undefined } +} + +test('health: GET /api/health answers 200 with exactly four fields, ahead of the sign-in wall', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + + const { status, headers, json } = await health(server) + assert.equal(status, 200) + assert.match(headers['content-type'], /application\/json/) + assert.deepEqual(Object.keys(json).sort(), FIELDS, 'a fifth field must fail this test') + assert.equal(json.ok, true) + assert.equal(json.version, PKG.version) + assert.ok(typeof json.version === 'string' && json.version.length > 0, 'version is a non-empty string') + assert.ok(json.commit === null || /^[0-9a-f]{40}$/.test(json.commit), `commit is a 40-hex sha or null, not ${json.commit}`) + assert.equal(new Date(json.startedAt).toISOString(), json.startedAt, 'startedAt is an ISO timestamp') +}) + +test('health: the answer is the same while authz is enforced, and needs no credentials', async () => { + const server = webServer() + server.register(signInWall()) + const { rt } = boot({ server }) + rt.authzEnforced = () => true + + const first = await health(server) + const second = await health(server) + assert.equal(first.status, 200) + assert.equal(second.status, 200) + assert.deepEqual(Object.keys(first.json).sort(), FIELDS) + assert.deepEqual(second.json, first.json, 'two requests in one process agree') +}) + +test('health: the rest of /api still meets the wall, and a disposed route gives it back', async () => { + const server = webServer() + server.register(signInWall()) + const { ctx } = boot({ server }) + + for (const path of ['/api/state', '/api/bot', '/api/health/extra']) { + const { status, body } = await request(server, path) + assert.equal(status, 401, `${path} is still behind the wall`) + assert.equal(body, 'unauthorized') + } + assert.equal((await request(server, '/')).status, 404) + + await ctx.dispose() + assert.equal((await request(server, HEALTH_PATH)).status, 401, 'the route went away with the plugin') +}) + +test('health: the Host/Origin fence decides before either the answer or the method', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + + const foreign = await health(server, { headers: { host: 'rebind.example:3080' } }) + assert.equal(foreign.status, 403, 'a Host that is neither loopback, the bind address nor trusted is refused') + assert.equal(foreign.body, 'forbidden') + assert.equal((await request(server, '/api/state', { headers: { host: 'rebind.example:3080' } })).status, 403, 'and the wall refuses it the same way') + + const crossSite = await health(server, { headers: { ...LOOPBACK, 'sec-fetch-site': 'cross-site' } }) + assert.equal(crossSite.status, 403, 'a cross-site marker is refused even from loopback') + const foreignOrigin = await health(server, { headers: { ...LOOPBACK, origin: 'http://rebind.example' } }) + assert.equal(foreignOrigin.status, 403, 'an Origin that is not this authority is refused') + const sameOrigin = await health(server, { headers: { ...LOOPBACK, origin: 'http://127.0.0.1:3080' } }) + assert.equal(sameOrigin.status, 200, 'a same-origin loopback request still answers') + + const noHost = await health(server, { headers: {} }) + assert.equal(noHost.status, 403, 'a request without a Host is not trusted') + + const trusted = await health(server, { headers: { host: 'monitor.example:3080' } }) + assert.equal(trusted.status, 200, 'a configured authority is served without a session, as elsewhere') + assert.deepEqual(Object.keys(trusted.json).sort(), FIELDS) + assert.equal((await request(server, '/api/state', { headers: { host: 'monitor.example:3080' } })).status, 401, 'but the rest of /api still wants a session') + + const headFromForeign = await request(server, HEALTH_PATH, { method: 'HEAD', headers: { host: 'rebind.example:3080' } }) + assert.equal(headFromForeign.status, 403, 'the fence runs before the method is read') +}) + +// The web server's own answer to a handler that throws: `handle(...).catch` logs and, while no +// header went out, writes 400 (`@deepseek-ai/dsh-host-webserver@0.2.1-rc.2` `lib/index.js:248-256`). +// `serve()` calls a handler directly, so this repeats that arm: a route that throws must reach it. +async function serveThroughServer(server, path, { method = 'GET', headers = LOOPBACK } = {}) { + const res = { + status: undefined, + chunks: [], + writeHead(status) { + this.status = status + }, + end(chunk) { + if (chunk !== undefined && chunk !== null) this.chunks.push(Buffer.from(chunk)) + }, + } + try { + await server.exact.get(new URL(path, 'http://x').pathname).handler({ method, url: path, headers }, res) + } catch { + if (res.status === undefined) { + res.status = 400 + res.end() + } + } + return { status: res.status, body: Buffer.concat(res.chunks).toString('utf8') } +} + +// The fence capability is not optional: a composition that hands the plugin a connection without +// `requestRejection` must not get a health answer, because the answer would have skipped the +// Host/Origin fence every other /api path meets. `admit()` reads the same call as a hard member, +// so a missing one is a server error — 400 from the arm above — and never a 200 (#2129, Security). +test('health: a connection without the fence capability answers 400, not a health answer', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server, fence: {} }) + + const { status, body } = await serveThroughServer(server, HEALTH_PATH, { headers: { host: 'rebind.example:3080' } }) + assert.equal(status, 400, 'a missing fence capability is a server error, not a request that passed the fence') + for (const field of FIELDS) assert.doesNotMatch(body, new RegExp(`"${field}"`), `no ${field} leaves without the fence's answer`) + + const loopback = await serveThroughServer(server, HEALTH_PATH) + assert.equal(loopback.status, 400, 'and a request that would have been trusted is refused the same way') +}) + +test('health: a method other than GET is not served as health', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + const { status, headers } = await health(server, { method: 'POST' }) + assert.equal(status, 405) + assert.equal(headers.allow, 'GET') +}) + +test('health: HEAD and OPTIONS get 405 with allow: GET and no body', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + + for (const method of ['HEAD', 'OPTIONS']) { + const { status, headers, body } = await request(server, HEALTH_PATH, { method }) + assert.equal(status, 405, `${method} is not GET`) + assert.equal(headers.allow, 'GET') + assert.equal(body, '', `${method} carries no payload`) + } +}) + +test('health: a query string does not change the route or the answer', async () => { + const server = webServer() + server.register(signInWall()) + boot({ server }) + + const plain = await health(server) + const queried = await request(server, `${HEALTH_PATH}?x=1`) + assert.equal(queried.status, 200, 'the pathname decides, not the query') + assert.deepEqual(Object.keys(JSON.parse(queried.body)).sort(), FIELDS) + assert.deepEqual(JSON.parse(queried.body), plain.json) +}) + +test('health: the payload keeps four fields and a non-empty version whatever it is handed', () => { + const fromOptions = healthPayload({ version: '9.9.9', commit: null, startedAt: '2026-01-01T00:00:00.000Z', env: 'production', host: 'box' }) + assert.deepEqual(Object.keys(fromOptions).sort(), FIELDS) + assert.equal(fromOptions.version, '9.9.9') + assert.deepEqual(Object.keys(healthPayload()).sort(), FIELDS) + for (const version of ['', ' ', undefined, null, 42]) { + const payload = healthPayload({ version, commit: null, startedAt: '2026-01-01T00:00:00.000Z' }) + assert.deepEqual(Object.keys(payload).sort(), FIELDS, `version ${JSON.stringify(version)} must not drop the key`) + assert.ok(typeof payload.version === 'string' && payload.version.trim().length > 0, `version ${JSON.stringify(version)} became ${JSON.stringify(payload.version)}`) + } + assert.throws(() => assert.deepEqual(Object.keys({ ...healthPayload(), host: 'box' }).sort(), FIELDS), /Expected values to be strictly deep-equal|host/) +}) + +test('health: startedAt is the process start, stable in one process and new in the next', async () => { + const now = Date.now() + const early = startedAtOf(now, 60_000) + const late = startedAtOf(now, 1_000) + assert.notEqual(early, late) + assert.equal(Date.parse(early), now - 60_000) + + const server = webServer() + server.register(signInWall()) + boot({ server }) + const a = (await health(server)).json.startedAt + const b = (await health(server)).json.startedAt + assert.equal(a, b) + assert.ok(Math.abs(Date.now() - Date.parse(a)) < 10 * 60_000, 'the process started minutes ago, not at request time') +}) + +test('health: commit is the checkout HEAD inside a git clone and null without one', t => { + const repoRoot = dirname(CORE_ROOT) + let expected + try { + expected = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim() + } catch { + expected = undefined + } + const home = mkdtempSync(join(tmpdir(), 'ds-bot-health-')) + try { + assert.equal(readCommit(home), null, 'no .git above the module means no commit') + if (expected === undefined) { + t.diagnostic('git is not available here; the checkout half of this test is skipped') + return + } + assert.equal(readCommit(repoRoot), expected) + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) + +test("health: commit is null when the package sits in someone else's checkout", t => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-health-')) + try { + const project = join(home, 'project') + const nodeModules = join(project, 'node_modules') + const installed = join(nodeModules, 'ds-bot') + mkdirSync(installed, { recursive: true }) + writeFileSync(join(installed, 'package.json'), '{"name":"ds-bot","version":"0.0.0"}\n') + let head + try { + execFileSync('git', ['init', '-q', project]) + execFileSync('git', ['-C', project, 'config', 'user.email', 'dev1@flybot.local']) + execFileSync('git', ['-C', project, 'config', 'user.name', 'Dev-1']) + writeFileSync(join(project, 'README.md'), "the user's project\n") + execFileSync('git', ['-C', project, 'add', '.']) + execFileSync('git', ['-C', project, 'commit', '-qm', 'project']) + head = execFileSync('git', ['-C', project, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() + } catch { + t.diagnostic('git is not available here; the nested-checkout half is skipped') + return + } + assert.equal(readCommit(nodeModules), null, `the project's HEAD ${head} at the layer above node_modules is not this package's commit`) + assert.equal(readCommit(project), head, 'the directory a checkout put core/ in is still read') + assert.equal(readCommit(installed), null, 'the package directory itself is not a checkout') + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) + +test('health: install without a web server registers nothing and does not throw', () => { + const { ctx } = boot({ server: undefined }) + assert.equal(ctx.calls.filter(call => call.startsWith('effect')).length, 0) +})