From 761e86f2488d6302f56a66ed632aba1291d5cfb5 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 05:39:26 +0800 Subject: [PATCH 1/2] fix(plugins): resolve DSH_HOME to an absolute root like the core The three peer plugins recomputed the DSH data root with `process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh')`: an empty DSH_HOME or an unset HOME made every persisted path relative to the process's working directory, so two cwds produced two traces and Bot Social read and wrote its credentials in the wrong folder. Each plugin now carries the core's rule (core/src/host/folders.js#dshHomeOf): a blank value counts as unset, `~` expands, and the result is resolved absolute. Fixes #2643 Written-by: Dev --- changelog.d/2643.fixed.md | 1 + docs/subsystems/social.md | 2 + docs/subsystems/timeline.md | 2 + docs/subsystems/workflow.md | 2 + plugins/social/index.js | 3 +- plugins/social/src/dsh-home.js | 9 +++ plugins/social/test/dsh-home.test.js | 95 ++++++++++++++++++++++++++ plugins/timeline/index.js | 3 +- plugins/timeline/src/dsh-home.js | 9 +++ plugins/timeline/test/dsh-home.test.js | 81 ++++++++++++++++++++++ plugins/workflow/index.js | 3 +- plugins/workflow/src/dsh-home.js | 9 +++ plugins/workflow/test/dsh-home.test.js | 95 ++++++++++++++++++++++++++ 13 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2643.fixed.md create mode 100644 plugins/social/src/dsh-home.js create mode 100644 plugins/social/test/dsh-home.test.js create mode 100644 plugins/timeline/src/dsh-home.js create mode 100644 plugins/timeline/test/dsh-home.test.js create mode 100644 plugins/workflow/src/dsh-home.js create mode 100644 plugins/workflow/test/dsh-home.test.js diff --git a/changelog.d/2643.fixed.md b/changelog.d/2643.fixed.md new file mode 100644 index 000000000..99ab43c27 --- /dev/null +++ b/changelog.d/2643.fixed.md @@ -0,0 +1 @@ +The `social`, `timeline` and `workflow` plugins now resolve `$DSH_HOME` the way the core does — a blank or whitespace value counts as unset, `~` expands, and the root is always absolute — so `bot/flybot-social.json`, `bot/flybot-timeline` and `bot/flybot-workflow` no longer move with the process's working directory ([#2643](https://github.com/ThinkFlowLab/FlyBot/issues/2643)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dd91cc1d3..3da2eb2bc 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,6 +10,8 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. +`$DSH_HOME` is resolved the way the core resolves it — a blank value counts as unset, `~` expands, and the root is absolute — so `bot/flybot-social.json` does not move with the process's working directory (#2643). + Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Join by number or link** (zh 用号码或链接加入) and **Start a face-to-face room** (zh 面对面建群, #1021) are two rows, because one label naming a create path and a join path at once read as neither (#892 had one); **Add friend** is the third (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. Both face rows open the same screen with the same handlers, titled after the row that was tapped; the start path keeps the six-digit number (a room is built by typing one together) and drops the link and scan fields, which are ways into a group that already exists. The join path's copy names both ways in and its link field reads *Or paste a group invite link* (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link — one row per **Bot**, not per person, labelled `Bot (Owner)` and carrying that Bot's handle and `peer_bot_id`, so a friend who runs several Bots gets a row each and the click grants to the row's own Bot while the receipt names it (#973) — from the row label the click carries, not from the home key, so a server answer without names never prints `selftestb2` (#994), and a caller that sends no label gets `with your friend` instead; the filter asks per **Bot** too, so a friend whose Bot already has it still gets a row for the others and only a grant that names no Bot falls back to hiding that whole friend (#981); the friend is named by `friend_bot_id`, the peer id `list_friends` returns — the platform never lists a friend's owner id — and `friend_owner_id` still works for a server that only knows it (#592); a server without that tool tries the REST route `POST /v1/bots/:id/grants` and, when it 404s too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of the raw MCP error (the production `MCP error -32602: Tool … not found` wording counts as an unknown tool, #563); a server that has the tool but refuses the arguments says *Bot Social did not accept that share request. Its server may need an update; invite links still work.*, never the raw `-32602` text (#592); A failed **Share** and a failed friend-list read keep separate notes (#987) — two slots, not one (#992): the share outcome (`share-note`) and the list error (`share-list-error`) are drawn side by side, so a share click cannot erase the list error and its **Try again**, and the 5 s poll cannot erase the share's message; a successful read settles the list error only, a successful share settles the share line only, and "No friends left to share with" is suppressed by a list error only — a share error never speaks for the list. **People with access** lists one row per **Bot** that has this Bot — named `Bot (Owner)` like the share list, carrying that Bot's handle, the groups it is used in, and its own **Revoke** (#995). **Revoke** asks once before it runs (#1073): the button opens `Revoke ? They lose this Bot.` (and `in N rooms` when that row already names rooms) with **Revoke** and **Keep**, and never the browser confirm box. The handle, or the home when the row names no Bot, and the usage hint share one line with a middle dot between them (`@handle · In use in N rooms`), so the two never run together (#1073). One **Auto-send** switch per person. The hint stays one readable row however long the share lives (#955): it names at most three rooms and then `+N more`, keeps every room one per line in its tooltip, and a room a grant row already named is not printed again under **This room only**. There is no published toggle. A line that Bot sends 1:1 to a seat in that room is judged under the room (#948): the plugin resolves it before the member check (`roomFor` → `roomContextFor`), the seat itself is the target when the roster does not list it (`pairFor`), and the send names that room (`room_id`) so Bot Social checks the same consent the host already did; the `roomOnly` identity is neither dropped nor widened — with no such room the refusal is the same sentence as before, and `send_as_member` is never reached. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index 28fa93b4c..1f4d9909a 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -8,6 +8,8 @@ owned_paths: Where time goes across the local Bot team (#241). The plugin records Bot turns, tool calls and Bot-to-Bot deliveries from the core's activity feed, keeps a capped trace on disk, and answers the panel's poll on `flybot/timeline/window`. It lives entirely under `plugins/timeline/`. +`$DSH_HOME` is resolved the way the core resolves it — a blank value counts as unset, `~` expands, and the root is absolute — so `bot/flybot-timeline` does not move with the process's working directory (#2643). + ## Owns Everything in `plugins/timeline/`: the recorder, the trace file, the read model behind the endpoint and the browser entry (`client.js`: data layer and the panel). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index c8e882c26..47e12a33f 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -8,6 +8,8 @@ owned_paths: A group-shaped chat of local Bots, with a flowchart the owner can drag once **Edit** is on (#1318, #1378). The plugin stores the graphs under `$DSH_HOME/bot/flybot-workflow` and answers `flybot/workflow/*`. It lives entirely under `plugins/workflow/`. +`$DSH_HOME` is resolved the way the core resolves it — a blank value counts as unset, `~` expands, and the root is absolute — so `bot/flybot-workflow` does not move with the process's working directory (#2643). + This is not the workflow-definition engine. That engine (#1272) stores `workflows/.json` and guards artifacts. This plugin does not read or write those files and does not call that engine. Settings → Workflows (#1364) is this plugin's list. The page lives in the core client and calls `workflow/state`, `workflow/create` and `workflow/graph`. Factory rows are `templateCatalog()` — id, name, description, triggers, plus the drawable `nodes`, `edges`, `layers` and `chain` — and the owner's rows are the saved workflows. The settings page does not replace the sidebar chat. A sidebar click opens this plugin's stage and leaves the Workflows section in place. diff --git a/plugins/social/index.js b/plugins/social/index.js index 2b425b19e..10f449bd9 100644 --- a/plugins/social/index.js +++ b/plugins/social/index.js @@ -15,6 +15,7 @@ // presence false turns off "Name is thinking…" for friends' Bots (Bot Social presence signal, #297; default on) // token / botId legacy per-bot identity (no home): owner connector token + bot id import { join } from 'node:path' +import { dshHomeOf } from './src/dsh-home.js' import { NS, registerUnavailable, startFailureReason, startSocial } from './src/social.js' import { createServerController } from './src/server-config.js' @@ -26,7 +27,7 @@ export function apply(ctx, config = {}) { // Owner switch (#2564): off means this process registers none of the plugin's tools, // slots or endpoints. Read from FlyBot's state.json at start, so it applies next launch. if (ctx.flybot?.pluginEnabled?.('social') === false) return - const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh') + const dshHome = dshHomeOf() const base = { inboxPollMs: 15_000, facePollMs: 4_000, credentialsPath: join(dshHome, 'bot', 'flybot-social.json'), ...config } const flybot = typeof ctx.flybot.scope === 'function' ? ctx.flybot.scope(NS) : ctx.flybot const warn = (...args) => console.warn('flybot-social:', ...args) diff --git a/plugins/social/src/dsh-home.js b/plugins/social/src/dsh-home.js new file mode 100644 index 000000000..057c3cfe0 --- /dev/null +++ b/plugins/social/src/dsh-home.js @@ -0,0 +1,9 @@ +// The FlyBot data root, resolved the way the core does (core/src/host/folders.js#dshHomeOf): +// a blank DSH_HOME counts as unset, `~` expands, and the answer is absolute. A peer plugin +// cannot import core/, so the rule is kept here rather than depending on a core path. +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const expandHome = text => (text === '~' ? homedir() : /^~[\\/]/.test(text) ? join(homedir(), text.slice(2)) : text) + +export const dshHomeOf = (value = process.env.DSH_HOME) => resolve(expandHome(value?.trim() ? value : join(homedir(), '.dsh'))) diff --git a/plugins/social/test/dsh-home.test.js b/plugins/social/test/dsh-home.test.js new file mode 100644 index 000000000..d97825d9a --- /dev/null +++ b/plugins/social/test/dsh-home.test.js @@ -0,0 +1,95 @@ +// The credentials root is the DSH home, resolved the way the core does (#2643): a blank +// DSH_HOME counts as unset and `~` expands, and the file must not move with the process's +// working directory (a wrong root is a wrong-file write, not a display matter). The default +// subdirectory (`bot/flybot-social.json`) is unchanged. +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { dshHomeOf } from '../src/dsh-home.js' + +const fakeFlybot = (handlers) => { + const fb = { + api: { handle: (name, fn) => { handlers.set(name, fn); return () => {} } }, + fields: { get: () => undefined, set: () => {} }, + grants: { of: () => [], set: async () => {}, has: () => false }, + bot: { list: () => [] }, + rooms: { list: () => [] }, + readOnly: () => true, + warn: () => {}, + on: () => () => {}, + } + return { ...fb, pluginEnabled: () => true, scope: () => fb } +} + +const credentialsFile = home => join(home, '.dsh', 'bot', 'flybot-social.json') + +function credentialsAt(home, server) { + mkdirSync(join(home, '.dsh', 'bot'), { recursive: true }) + writeFileSync(credentialsFile(home), JSON.stringify({ server, token: 'test-token' }), { mode: 0o600 }) +} + +async function statusAt(cwd, home) { + process.chdir(cwd) + const handlers = new Map() + const ctx = { flybot: fakeFlybot(handlers), effect(fn) { this.off = fn() } } + apply(ctx) + try { + return await handlers.get('social/server')() + } finally { + ctx.off?.() + } +} + +async function withEnv(env, run) { + const saved = { DSH_HOME: process.env.DSH_HOME, HOME: process.env.HOME, cwd: process.cwd() } + Object.assign(process.env, env) + try { + return await run() + } finally { + for (const key of ['DSH_HOME', 'HOME']) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + process.chdir(saved.cwd) + } +} + +test('the credentials file follows the DSH home whatever the working directory', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-social-home-')) + const a = mkdtempSync(join(tmpdir(), 'flybot-social-cwd-a-')) + const b = mkdtempSync(join(tmpdir(), 'flybot-social-cwd-b-')) + try { + credentialsAt(home, 'https://peer.test') + await withEnv({ DSH_HOME: '', HOME: home }, async () => { + const first = await statusAt(a, home) + const second = await statusAt(b, home) + assert.equal(isAbsolute(dshHomeOf()), true, 'the resolved root is absolute') + assert.deepEqual(first.known, ['https://peer.test'], 'the saved server is read from the DSH home') + assert.deepEqual(second.known, first.known, 'two working directories resolve to one credentials root') + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(a, { recursive: true, force: true }) + rmSync(b, { recursive: true, force: true }) + } +}) + +test('a blank DSH_HOME counts as unset and ~ expands like the core', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-social-home-')) + try { + await withEnv({ DSH_HOME: ' ', HOME: home }, () => { + assert.equal(dshHomeOf(), join(home, '.dsh')) + }) + await withEnv({ DSH_HOME: '~/.dsh', HOME: home }, () => { + assert.equal(dshHomeOf(), join(home, '.dsh')) + }) + await withEnv({ DSH_HOME: '~/data', HOME: home }, () => { + assert.equal(dshHomeOf(), join(home, 'data')) + }) + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/plugins/timeline/index.js b/plugins/timeline/index.js index b8818ed12..def411452 100644 --- a/plugins/timeline/index.js +++ b/plugins/timeline/index.js @@ -25,6 +25,7 @@ import { appendFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { createAdapter } from './src/adapter.js' +import { dshHomeOf } from './src/dsh-home.js' import { createTimeline } from './src/timeline.js' export const name = 'flybot-timeline' @@ -95,7 +96,7 @@ export function apply(ctx, config = {}) { if (ctx.flybot?.pluginEnabled?.('timeline') === false) return { timeline: undefined, enabled: false, dispose() {} } if (config?.enabled !== true) return { timeline: undefined, enabled: false, dispose() {} } const adapter = createAdapter(ctx.flybot) - const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh') + const dshHome = dshHomeOf() const { dir = join(dshHome, 'bot', 'flybot-timeline'), persist, segmentRecords, segments, retentionMs, flushMs } = config const limits = Object.fromEntries(Object.entries({ segmentRecords, segments, retentionMs, flushMs }).filter(([, value]) => Number.isFinite(value) && value > 0)) const timeline = createTimeline(adapter, { dir, persist: persist !== false, exporter: makeExporter(config, dir, adapter.warn), ...limits }) diff --git a/plugins/timeline/src/dsh-home.js b/plugins/timeline/src/dsh-home.js new file mode 100644 index 000000000..057c3cfe0 --- /dev/null +++ b/plugins/timeline/src/dsh-home.js @@ -0,0 +1,9 @@ +// The FlyBot data root, resolved the way the core does (core/src/host/folders.js#dshHomeOf): +// a blank DSH_HOME counts as unset, `~` expands, and the answer is absolute. A peer plugin +// cannot import core/, so the rule is kept here rather than depending on a core path. +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const expandHome = text => (text === '~' ? homedir() : /^~[\\/]/.test(text) ? join(homedir(), text.slice(2)) : text) + +export const dshHomeOf = (value = process.env.DSH_HOME) => resolve(expandHome(value?.trim() ? value : join(homedir(), '.dsh'))) diff --git a/plugins/timeline/test/dsh-home.test.js b/plugins/timeline/test/dsh-home.test.js new file mode 100644 index 000000000..082ea1ce3 --- /dev/null +++ b/plugins/timeline/test/dsh-home.test.js @@ -0,0 +1,81 @@ +// The trace root is the DSH home, resolved the way the core does (#2643): a blank DSH_HOME +// counts as unset and `~` expands, and the answer must not move with the process's working +// directory. The default subdirectory (`bot/flybot-timeline`) is unchanged. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' + +const flybot = () => ({ + scope: () => ({ + on: () => () => {}, + bot: { list: () => [] }, + rooms: { list: () => [] }, + readOnly: () => true, + warn: () => {}, + api: { handle: () => () => {} }, + }), +}) + +function traceDirAt(cwd) { + process.chdir(cwd) + const ctx = { flybot: flybot(), effect(fn) { this.off = fn() } } + const result = apply(ctx, { enabled: true, persist: false }) + const dir = result.timeline.trace.dir + result.dispose() + return dir +} + +function withEnv(env, run) { + const saved = { DSH_HOME: process.env.DSH_HOME, HOME: process.env.HOME, cwd: process.cwd() } + Object.assign(process.env, env) + try { + return run() + } finally { + for (const key of ['DSH_HOME', 'HOME']) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + process.chdir(saved.cwd) + } +} + +test('the trace root is absolute and does not follow the working directory', () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-timeline-home-')) + const a = mkdtempSync(join(tmpdir(), 'flybot-timeline-cwd-a-')) + const b = mkdtempSync(join(tmpdir(), 'flybot-timeline-cwd-b-')) + try { + withEnv({ DSH_HOME: '', HOME: home }, () => { + const first = traceDirAt(a) + const second = traceDirAt(b) + assert.equal(isAbsolute(first), true, 'the trace root is absolute') + assert.equal(first, join(home, '.dsh', 'bot', 'flybot-timeline')) + assert.equal(second, first, 'two working directories resolve to one trace root') + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(a, { recursive: true, force: true }) + rmSync(b, { recursive: true, force: true }) + } +}) + +test('a blank DSH_HOME counts as unset and ~ expands like the core', () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-timeline-home-')) + const cwd = mkdtempSync(join(tmpdir(), 'flybot-timeline-cwd-')) + try { + withEnv({ DSH_HOME: ' ', HOME: home }, () => { + assert.equal(traceDirAt(cwd), join(home, '.dsh', 'bot', 'flybot-timeline')) + }) + withEnv({ DSH_HOME: '~/.dsh', HOME: home }, () => { + assert.equal(traceDirAt(cwd), join(home, '.dsh', 'bot', 'flybot-timeline')) + }) + withEnv({ DSH_HOME: '/var/lib/flybot', HOME: home }, () => { + assert.equal(traceDirAt(cwd), '/var/lib/flybot/bot/flybot-timeline') + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + } +}) diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index 78325029d..148887568 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -2,6 +2,7 @@ // $DSH_HOME/bot/flybot-workflow and answers the chat surface on workflow/*. import { randomBytes } from 'node:crypto' import { join } from 'node:path' +import { dshHomeOf } from './src/dsh-home.js' import { permissionFor } from './src/ephemeral.js' import { createWorkflowService } from './src/service.js' @@ -27,7 +28,7 @@ export function apply(ctx, config = {}) { throw new Error('@flybot/workflow needs ctx.flybot.scope (flybot API v1)') } const fb = ctx.flybot.scope('flybot-workflow') - const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh') + const dshHome = dshHomeOf() const persist = config.persist !== false // The runner names held by the ephemeral Bots this plugin spawned, keyed by // Bot id, so a release gives its name back instead of keeping it for the diff --git a/plugins/workflow/src/dsh-home.js b/plugins/workflow/src/dsh-home.js new file mode 100644 index 000000000..057c3cfe0 --- /dev/null +++ b/plugins/workflow/src/dsh-home.js @@ -0,0 +1,9 @@ +// The FlyBot data root, resolved the way the core does (core/src/host/folders.js#dshHomeOf): +// a blank DSH_HOME counts as unset, `~` expands, and the answer is absolute. A peer plugin +// cannot import core/, so the rule is kept here rather than depending on a core path. +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const expandHome = text => (text === '~' ? homedir() : /^~[\\/]/.test(text) ? join(homedir(), text.slice(2)) : text) + +export const dshHomeOf = (value = process.env.DSH_HOME) => resolve(expandHome(value?.trim() ? value : join(homedir(), '.dsh'))) diff --git a/plugins/workflow/test/dsh-home.test.js b/plugins/workflow/test/dsh-home.test.js new file mode 100644 index 000000000..aad78d688 --- /dev/null +++ b/plugins/workflow/test/dsh-home.test.js @@ -0,0 +1,95 @@ +// The workflow store root is the DSH home, resolved the way the core does (#2643): a blank +// DSH_HOME counts as unset and `~` expands, and the store must not move with the process's +// working directory. The default subdirectory (`bot/flybot-workflow`) is unchanged. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' + +const fakeFlybot = (handlers) => { + const fb = { + api: { handle: (name, fn) => { handlers.set(name, fn); return () => {} } }, + tools: { register: () => () => {} }, + bot: { list: () => [] }, + rooms: { list: () => [] }, + readOnly: () => false, + warn: () => {}, + } + return { ...fb, pluginEnabled: () => true, scope: () => fb } +} + +async function startAt(cwd, handlers) { + process.chdir(cwd) + const ctx = { flybot: fakeFlybot(handlers), effect(fn) { this.off = fn() } } + const plugin = apply(ctx, {}) + return { plugin, stop: async () => { await plugin.dispose() } } +} + +async function withEnv(env, run) { + const saved = { DSH_HOME: process.env.DSH_HOME, HOME: process.env.HOME, cwd: process.cwd() } + Object.assign(process.env, env) + try { + return await run() + } finally { + for (const key of ['DSH_HOME', 'HOME']) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + process.chdir(saved.cwd) + } +} + +const storeFile = (home, name) => join(home, '.dsh', 'bot', 'flybot-workflow', name) + +test('the workflow store lands in the DSH home whatever the working directory', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-workflow-home-')) + const a = mkdtempSync(join(tmpdir(), 'flybot-workflow-cwd-a-')) + const b = mkdtempSync(join(tmpdir(), 'flybot-workflow-cwd-b-')) + try { + await withEnv({ DSH_HOME: '', HOME: home }, async () => { + const first = await startAt(a, new Map()) + try { + const created = first.plugin.service.create({ name: 'Root', nodes: [{ id: 'n1', name: 'Step', prompt: 'Do the thing' }], layers: [['n1']] }) + assert.equal(created.ok, true, `create answered ${JSON.stringify(created)}`) + assert.equal(isAbsolute(storeFile(home, 'workflows.json')), true) + assert.equal(existsSync(storeFile(home, 'workflows.json')), true, 'the store is under the DSH home') + assert.equal(existsSync(join(a, 'bot', 'flybot-workflow', 'workflows.json')), false, 'nothing is written beside the cwd') + } finally { + await first.stop() + } + + const second = await startAt(b, new Map()) + try { + const names = second.plugin.service.state().workflows.map(item => item.name) + assert.equal(names.includes('Root'), true, `another working directory reads the same store (${names.join(', ')})`) + } finally { + await second.stop() + } + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(a, { recursive: true, force: true }) + rmSync(b, { recursive: true, force: true }) + } +}) + +test('a blank DSH_HOME counts as unset and ~ expands like the core', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-workflow-home-')) + const cwd = mkdtempSync(join(tmpdir(), 'flybot-workflow-cwd-')) + try { + await withEnv({ DSH_HOME: ' ', HOME: home }, async () => { + const run = await startAt(cwd, new Map()) + try { + assert.equal(run.plugin.service.create({ name: 'Blank', nodes: [{ id: 'n1', name: 'Step', prompt: 'Do the thing' }], layers: [['n1']] }).ok, true) + assert.equal(existsSync(storeFile(home, 'workflows.json')), true) + } finally { + await run.stop() + } + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + } +}) From 0fc8eedce614124f0f16ff380a5a24ef241f4010 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 05:43:36 +0800 Subject: [PATCH 2/2] test(social): drop the unused home parameter Lint rejected the helper's unused second parameter, which turned the plugin suite red on 761e86f. Written-by: Dev --- plugins/social/test/dsh-home.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/social/test/dsh-home.test.js b/plugins/social/test/dsh-home.test.js index d97825d9a..1fc6c9bf7 100644 --- a/plugins/social/test/dsh-home.test.js +++ b/plugins/social/test/dsh-home.test.js @@ -31,7 +31,7 @@ function credentialsAt(home, server) { writeFileSync(credentialsFile(home), JSON.stringify({ server, token: 'test-token' }), { mode: 0o600 }) } -async function statusAt(cwd, home) { +async function statusAt(cwd) { process.chdir(cwd) const handlers = new Map() const ctx = { flybot: fakeFlybot(handlers), effect(fn) { this.off = fn() } } @@ -64,8 +64,8 @@ test('the credentials file follows the DSH home whatever the working directory', try { credentialsAt(home, 'https://peer.test') await withEnv({ DSH_HOME: '', HOME: home }, async () => { - const first = await statusAt(a, home) - const second = await statusAt(b, home) + const first = await statusAt(a) + const second = await statusAt(b) assert.equal(isAbsolute(dshHomeOf()), true, 'the resolved root is absolute') assert.deepEqual(first.known, ['https://peer.test'], 'the saved server is read from the DSH home') assert.deepEqual(second.known, first.known, 'two working directories resolve to one credentials root')