Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Only what the image build needs (#352): the packages, their pack check and docker/.
.git
**/node_modules
**/.dsh*
**/.env
**/.credentials.yaml
core/dev/harness/node_modules
ds-bot
docs/images
*.log
/deepseek-harness
92 changes: 92 additions & 0 deletions .github/scripts/docker-profile.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// docker/: the container's web profile (#352) and the files the image and compose rely on.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { imagePackages, linkPackages, prepareProfile } from '../../docker/profile.mjs'

const root = new URL('../../', import.meta.url)
const read = (path) => readFileSync(new URL(path, root), 'utf8')

function packagesDir() {
const dir = mkdtempSync(join(tmpdir(), 'flybot-pkgs-'))
for (const [folder, name] of [['ds-bot-0.2.1', 'ds-bot'], ['flybot-tree-0.1.0', '@flybot/tree'], ['flybot-social-0.1.0', '@flybot/social']]) {
mkdirSync(join(dir, folder))
writeFileSync(join(dir, folder, 'package.json'), JSON.stringify({ name }))
}
mkdirSync(join(dir, 'not-a-package'))
return dir
}

test('imagePackages lists the unpacked packages by name', () => {
const dir = packagesDir()
try {
assert.deepEqual(imagePackages(dir).map((p) => p.name), ['@flybot/social', '@flybot/tree', 'ds-bot'])
assert.equal(imagePackages(dir).find((p) => p.name === 'ds-bot').dir, join(dir, 'ds-bot-0.2.1'))
} finally { rmSync(dir, { recursive: true, force: true }) }
})

test('a new profile gets the web bundles plus every image package, linked', () => {
const next = linkPackages(undefined, [{ name: 'ds-bot', dir: '/opt/p/core' }, { name: '@flybot/tree', dir: '/opt/p/tree' }])
assert.deepEqual(next.dependencies, { 'ds-bot': 'link:/opt/p/core', '@flybot/tree': 'link:/opt/p/tree' })
assert.deepEqual(next.dsh.profile.bundles, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'ds-bot', '@flybot/tree'])
})

test('an existing profile keeps your own plugins and bundle order; old FlyBot entries are relinked', () => {
const current = {
name: 'dsh-profile-web',
private: true,
dependencies: { 'ds-bot': 'file:/old/ds-bot-0.2.0.tgz', 'my-plugin': '^1.0.0' },
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'my-plugin', 'ds-bot'] }, other: true },
}
const before = structuredClone(current)
const next = linkPackages(current, [{ name: 'ds-bot', dir: '/opt/p/core' }, { name: '@flybot/social', dir: '/opt/p/social' }])
assert.deepEqual(current, before, 'the input is not changed')
assert.deepEqual(next.dependencies, { 'ds-bot': 'link:/opt/p/core', 'my-plugin': '^1.0.0', '@flybot/social': 'link:/opt/p/social' })
assert.deepEqual(next.dsh.profile.bundles, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'my-plugin', 'ds-bot', '@flybot/social'])
assert.equal(next.dsh.other, true)
})

test('prepareProfile writes package.json and pnpm-workspace.yaml and keeps your settings', () => {
const pkgs = packagesDir()
const home = mkdtempSync(join(tmpdir(), 'flybot-home-'))
try {
const profile = join(home, 'profiles', 'web')
mkdirSync(profile, { recursive: true })
writeFileSync(join(profile, 'cordis.patch.yml'), '- id: flybot-social\n')
writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\n# mine\n')
assert.equal(prepareProfile(home, pkgs), profile)
const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8'))
assert.equal(manifest.dependencies['ds-bot'], `link:${join(pkgs, 'ds-bot-0.2.1')}`)
assert.equal(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8'), '- id: flybot-social\n')
assert.equal(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), 'packages:\n - .\n# mine\n')
prepareProfile(home, pkgs)
assert.deepEqual(JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')), manifest, 'a second start changes nothing')
rmSync(join(profile, 'pnpm-workspace.yaml'))
prepareProfile(home, pkgs)
assert.match(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), /nodeLinker: hoisted\nautoInstallPeers: false/)
} finally {
rmSync(pkgs, { recursive: true, force: true })
rmSync(home, { recursive: true, force: true })
}
})

test('secure defaults: loopback port, non-root user, no secret in the image, no registry push', () => {
const compose = read('compose.yaml')
assert.match(compose, /^ {6}- "127\.0\.0\.1:\$\{FLYBOT_PORT:-3080\}:3080"$/m, 'the port is published on the host loopback only')
assert.match(compose, /^ {6}- data:\/data$/m, 'one data volume')
assert.match(compose, /^ {6}- DEEPSEEK_API_KEY$/m, 'the key is passed through, never written into the file')
const dockerfile = read('Dockerfile')
assert.match(dockerfile, /^USER node$/m)
assert.match(dockerfile, /^VOLUME \["\/data"\]$/m)
assert.match(dockerfile, /^HEALTHCHECK /m)
assert.doesNotMatch(dockerfile, /^(ENV|ARG)\s.*(KEY|TOKEN|SECRET)/im, 'no key in the image')
const pinned = JSON.parse(read('core/dev/harness/package.json')).dependencies['@deepseek-ai/dsh']
assert.match(dockerfile, new RegExp(`^ARG DSH_VERSION=${pinned.replace(/\./g, '\\.')}$`, 'm'), 'DSH is pinned like the tests and the README')
const ignore = read('.dockerignore')
for (const entry of ['**/.env', '**/.credentials.yaml', '**/.dsh*', '.git']) assert.ok(ignore.split('\n').includes(entry), `.dockerignore lists ${entry}`)
const workflow = read('.github/workflows/docker.yml')
assert.doesNotMatch(workflow, /docker (push|login)|docker\/(login|build-push)-action|packages: write/, 'CI builds and checks only')
assert.match(read('docker/web.patch.yml'), /^ {4}host: 0\.0\.0\.0$/m)
})
69 changes: 69 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Container image check (#352): builds the image from this checkout and runs a start check
# (compose up until the health check passes, the page answers on the host's loopback, the
# process is not root). Nothing is pushed to a registry and nothing is deployed.
name: Docker

on:
pull_request:
paths:
- Dockerfile
- compose.yaml
- .dockerignore
- docker/**
- .github/scripts/check-pack.mjs
- .github/workflows/docker.yml
push:
branches: [main]
paths:
- Dockerfile
- compose.yaml
- .dockerignore
- docker/**
- .github/scripts/check-pack.mjs
- .github/workflows/docker.yml
workflow_dispatch:

permissions:
contents: read

concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true

jobs:
image:
name: build and start check
runs-on: ubuntu-latest
timeout-minutes: 20
env:
FLYBOT_TAG: ci
FLYBOT_PORT: '3080'
COMPOSE_PROJECT_NAME: flybot-ci
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build the image
run: docker compose build
- name: Start and wait for the health check
run: docker compose up -d --wait --wait-timeout 180
- name: Start check
run: |
set -eu
# Unauthenticated, the web server answers 401 on the host's loopback port.
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$FLYBOT_PORT/")
echo "GET / -> $code"
[ "$code" = 401 ]
# The sign-in link was printed (checked, never shown: it carries the token).
docker compose logs flybot | grep -q 'dsh web: http://127.0.0.1:3080/?token='
# Not root, and the FlyBot packages are linked from the image.
uid=$(docker compose exec -T flybot id -u)
echo "uid: $uid"
[ "$uid" != 0 ]
docker compose exec -T flybot node -e "const p=require('/data/profiles/web/package.json');const d=p.dependencies;for(const n of ['ds-bot','@flybot/social','@flybot/tree','@flybot/timeline'])if(!String(d[n]).startsWith('link:/opt/flybot/packages/'))throw new Error(n+' is not linked: '+d[n]);console.log('linked:',Object.keys(d).join(', '))"
- name: Logs (sign-in token removed)
if: always()
run: docker compose logs flybot 2>&1 | sed -E 's/token=[^ &)]*/token=REDACTED/g' | tail -n 60
- name: Stop
if: always()
run: docker compose down -v
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# FlyBot in a container (#352): one person, one instance. `dsh web` with the FlyBot core,
# social, tree and timeline packages of this checkout. Build it yourself; nothing is published.
# docker compose up -d --build (see docs/docker.md)
# No keys or tokens are baked in: DEEPSEEK_API_KEY comes from the environment or from the
# credentials file on the data volume.

ARG NODE_IMAGE=node:24-bookworm-slim

# Stage 1: pack the packages exactly as a DSH profile installs them (the release tarballs).
FROM ${NODE_IMAGE} AS pack
WORKDIR /src
COPY . .
RUN node .github/scripts/check-pack.mjs /pkgs \
&& for tgz in /pkgs/*.tgz; do \
dir="/out/$(basename "$tgz" .tgz)"; mkdir -p "$dir" && tar -xzf "$tgz" -C "$dir" --strip-components=1; \
done

# Stage 2: the runtime.
FROM ${NODE_IMAGE}
# DSH as pinned in the README; pnpm is what `dsh plugin` uses to manage the profile.
ARG DSH_VERSION=0.2.0-rc.2
ARG PNPM_VERSION=10.34.6
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git tini \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g --no-fund --no-audit "@deepseek-ai/dsh@${DSH_VERSION}" "pnpm@${PNPM_VERSION}" \
&& npm cache clean --force
COPY --from=pack /out/ /opt/flybot/packages/
COPY docker/ /opt/flybot/docker/
# The node image's unprivileged user (uid 1000). /data is the one volume: DSH home (profile,
# Bots, Sessions, credentials), the pnpm store and the Bots' workspace.
RUN mkdir -p /data && chown node:node /data
ENV DSH_HOME=/data HOME=/data NODE_ENV=production
USER node
WORKDIR /data
VOLUME ["/data"]
EXPOSE 3080
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD ["node", "/opt/flybot/docker/healthcheck.mjs"]
ENTRYPOINT ["tini", "--", "node", "/opt/flybot/docker/entrypoint.mjs"]
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 web --port 3080

Open the printed `http://127.0.0.1:3080/?token=…` link. The first time you open Chief, pick a model (or open **Models and providers…**).

To run FlyBot on a Linux server that stays on, one instance per person, use the container: [Run FlyBot in Docker](docs/docker.md).

### 4. Self-host Bot Social (for friends' Bots)

`@flybot/social` needs a [Bot Social](https://github.com/Birfy/bot-social) server. It does not ship one. Deploy Bot Social yourself (see its README), then set a public base URL so invite links work:
Expand Down
2 changes: 2 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 web --port 3080

打开打印出的 `http://127.0.0.1:3080/?token=…`。第一次打开 Chief 时选择模型。

想在一直开着的 Linux 服务器上运行(每人一个实例),可以用容器:[用 Docker 运行 FlyBot](docs/docker.zh.md)。

### 4. 自建 Bot Social(好友功能需要)

`@flybot/social` **依赖**你自己部署的 [Bot Social](https://github.com/Birfy/bot-social),仓库里不附带服务器。按 Bot Social 的 README 部署,并设置对外地址:
Expand Down
1 change: 1 addition & 0 deletions changelog.d/352-docker.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FlyBot can run in a container that you build yourself: a `Dockerfile` and `compose.yaml` start `dsh web` with the FlyBot core and plugins as a normal user, with one data volume, no keys in the image, the port on the host's `127.0.0.1` only and a health check. A guide in English and Chinese, [Run FlyBot in Docker](docs/docker.md), covers start, stop, upgrade, roll back and back up; CI builds the image and runs a start check, and nothing is published ([#352](https://github.com/ThinkFlowLab/FlyBot/issues/352)).
22 changes: 22 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# FlyBot, one instance per person (#352). Guide: docs/docker.md (中文: docs/docker.zh.md).
# docker compose up -d --build build this checkout and start
# docker compose logs flybot | grep token= the sign-in link
# More instances on one machine: a project name and a port each, for example
# FLYBOT_PORT=3081 docker compose -p alice up -d --build
services:
flybot:
build: .
image: flybot:${FLYBOT_TAG:-local}
restart: unless-stopped
ports:
# Host loopback only. For remote access use your own HTTPS proxy or an SSH tunnel;
# never publish this port on a public address.
- "127.0.0.1:${FLYBOT_PORT:-3080}:3080"
environment:
# Passed only when set in your shell or in .env next to this file; otherwise save the
# key in the app (it is kept on the data volume).
- DEEPSEEK_API_KEY
volumes:
- data:/data
volumes:
data:
23 changes: 23 additions & 0 deletions docker/entrypoint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env node
// Container entry point (#352): link the image's FlyBot packages into the web profile on the
// data volume, then run `dsh web` in the foreground. Keys come from the environment or from
// the credentials file on the volume; nothing secret is baked into the image.
import { execFileSync, spawn } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { prepareProfile } from './profile.mjs'

const dshHome = process.env.DSH_HOME || '/data'
const packagesDir = process.env.FLYBOT_PACKAGES || '/opt/flybot/packages'
const profile = prepareProfile(dshHome, packagesDir)
// Links need no download; a plugin you added yourself comes from the pnpm store on the volume.
execFileSync('pnpm', ['install', '--prefer-offline', '--silent'], { cwd: profile, stdio: 'inherit' })
const workspace = join(dshHome, 'workspace')
mkdirSync(workspace, { recursive: true })

// The web server listens on all interfaces of the container only (web.patch.yml); compose
// publishes that port on the host's 127.0.0.1. The sign-in link is DSH's own ?token= link.
const args = ['--profile', 'web', '--patch', join(import.meta.dirname, 'web.patch.yml'), '--no-open', '--port', '3080', ...process.argv.slice(2)]
const child = spawn('dsh', args, { cwd: workspace, stdio: 'inherit' })
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal))
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
8 changes: 8 additions & 0 deletions docker/healthcheck.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Docker health check (#352): healthy when the web server answers. Without the sign-in cookie
// it answers 401, which still proves it is up.
try {
const response = await fetch('http://127.0.0.1:3080/', { redirect: 'manual', signal: AbortSignal.timeout(4000) })
process.exit(response.status < 500 ? 0 : 1)
} catch {
process.exit(1)
}
48 changes: 48 additions & 0 deletions docker/profile.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Prepares the DSH `web` profile inside the FlyBot container (#352). The profile lives on the
// data volume ($DSH_HOME/profiles/web), so your settings (cordis.patch.yml) and any plugin you
// added yourself survive an image upgrade. The FlyBot packages are not copied into it: each
// start links them to the code baked into the image, so a new image is a new FlyBot.
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

// What `dsh plugin --profile web add` writes for a new web profile.
const BASE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
const WORKSPACE_YAML = 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n'

/** The FlyBot packages unpacked in the image: [{ name, dir }], sorted by name. */
export function imagePackages(packagesDir) {
return readdirSync(packagesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && existsSync(join(packagesDir, entry.name, 'package.json')))
.map((entry) => ({ name: JSON.parse(readFileSync(join(packagesDir, entry.name, 'package.json'), 'utf8')).name, dir: join(packagesDir, entry.name) }))
.sort((a, b) => a.name.localeCompare(b.name))
}

/**
* The profile manifest with every image package linked and listed as a bundle. Other
* dependencies and bundles are kept as they are; a FlyBot package that is already a bundle
* keeps its place.
* @param manifest - the current profile package.json, or undefined for a new profile.
* @param packages - from imagePackages().
*/
export function linkPackages(manifest, packages) {
const next = structuredClone(manifest ?? { name: 'dsh-profile-web', private: true })
next.dependencies = { ...next.dependencies }
for (const { name, dir } of packages) next.dependencies[name] = `link:${dir}`
next.dsh = { ...next.dsh, profile: { ...next.dsh?.profile } }
const bundles = [...(next.dsh.profile.bundles ?? BASE_BUNDLES)]
for (const { name } of packages) if (!bundles.includes(name)) bundles.push(name)
next.dsh.profile.bundles = bundles
return next
}

/** Writes the profile files; returns the profile directory. */
export function prepareProfile(dshHome, packagesDir) {
const dir = join(dshHome, 'profiles', 'web')
mkdirSync(dir, { recursive: true })
const manifestPath = join(dir, 'package.json')
const current = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : undefined
writeFileSync(manifestPath, JSON.stringify(linkPackages(current, imagePackages(packagesDir)), null, 2) + '\n')
const workspace = join(dir, 'pnpm-workspace.yaml')
if (!existsSync(workspace)) writeFileSync(workspace, WORKSPACE_YAML)
return dir
}
14 changes: 14 additions & 0 deletions docker/web.patch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Container-only overlay (#352). Inside the container DSH listens on every interface of the
# container's own network, so the published port can reach it; compose.yaml publishes that
# port on the host's 127.0.0.1 only. Never publish it on a public address: use your own
# HTTPS proxy or an SSH tunnel (docs/docker.md). A patch replaces the entry's whole config,
# so the other values repeat what the web profile composes.
- id: webserver
inject:
- webStartup
config:
host: 0.0.0.0
port: !!js ctx.webStartup.port ?? 3080
compression: gzip
compressionLevel: 1
compressionThresholdBytes: 1024
Loading
Loading