Skip to content

Commit 46f2eb5

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/366-model-labels
# Conflicts: # patches/ds-bot/0008-client-bundle.patch
2 parents cc9e9e8 + 674bcd7 commit 46f2eb5

37 files changed

Lines changed: 1797 additions & 485 deletions

‎.dockerignore‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Only what the image build needs (#352): the packages, their pack check and docker/.
2+
.git
3+
**/node_modules
4+
**/.dsh*
5+
**/.env
6+
**/.credentials.yaml
7+
core/dev/harness/node_modules
8+
ds-bot
9+
docs/images
10+
*.log
11+
/deepseek-harness
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// docker/: the container's web profile (#352) and the files the image and compose rely on.
2+
import { test } from 'node:test'
3+
import assert from 'node:assert/strict'
4+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
5+
import { tmpdir } from 'node:os'
6+
import { join } from 'node:path'
7+
import { imagePackages, linkPackages, prepareProfile } from '../../docker/profile.mjs'
8+
9+
const root = new URL('../../', import.meta.url)
10+
const read = (path) => readFileSync(new URL(path, root), 'utf8')
11+
12+
function packagesDir() {
13+
const dir = mkdtempSync(join(tmpdir(), 'flybot-pkgs-'))
14+
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']]) {
15+
mkdirSync(join(dir, folder))
16+
writeFileSync(join(dir, folder, 'package.json'), JSON.stringify({ name }))
17+
}
18+
mkdirSync(join(dir, 'not-a-package'))
19+
return dir
20+
}
21+
22+
test('imagePackages lists the unpacked packages by name', () => {
23+
const dir = packagesDir()
24+
try {
25+
assert.deepEqual(imagePackages(dir).map((p) => p.name), ['@flybot/social', '@flybot/tree', 'ds-bot'])
26+
assert.equal(imagePackages(dir).find((p) => p.name === 'ds-bot').dir, join(dir, 'ds-bot-0.2.1'))
27+
} finally { rmSync(dir, { recursive: true, force: true }) }
28+
})
29+
30+
test('a new profile gets the web bundles plus every image package, linked', () => {
31+
const next = linkPackages(undefined, [{ name: 'ds-bot', dir: '/opt/p/core' }, { name: '@flybot/tree', dir: '/opt/p/tree' }])
32+
assert.deepEqual(next.dependencies, { 'ds-bot': 'link:/opt/p/core', '@flybot/tree': 'link:/opt/p/tree' })
33+
assert.deepEqual(next.dsh.profile.bundles, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'ds-bot', '@flybot/tree'])
34+
})
35+
36+
test('an existing profile keeps your own plugins and bundle order; old FlyBot entries are relinked', () => {
37+
const current = {
38+
name: 'dsh-profile-web',
39+
private: true,
40+
dependencies: { 'ds-bot': 'file:/old/ds-bot-0.2.0.tgz', 'my-plugin': '^1.0.0' },
41+
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'my-plugin', 'ds-bot'] }, other: true },
42+
}
43+
const before = structuredClone(current)
44+
const next = linkPackages(current, [{ name: 'ds-bot', dir: '/opt/p/core' }, { name: '@flybot/social', dir: '/opt/p/social' }])
45+
assert.deepEqual(current, before, 'the input is not changed')
46+
assert.deepEqual(next.dependencies, { 'ds-bot': 'link:/opt/p/core', 'my-plugin': '^1.0.0', '@flybot/social': 'link:/opt/p/social' })
47+
assert.deepEqual(next.dsh.profile.bundles, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'my-plugin', 'ds-bot', '@flybot/social'])
48+
assert.equal(next.dsh.other, true)
49+
})
50+
51+
test('prepareProfile writes package.json and pnpm-workspace.yaml and keeps your settings', () => {
52+
const pkgs = packagesDir()
53+
const home = mkdtempSync(join(tmpdir(), 'flybot-home-'))
54+
try {
55+
const profile = join(home, 'profiles', 'web')
56+
mkdirSync(profile, { recursive: true })
57+
writeFileSync(join(profile, 'cordis.patch.yml'), '- id: flybot-social\n')
58+
writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\n# mine\n')
59+
assert.equal(prepareProfile(home, pkgs), profile)
60+
const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8'))
61+
assert.equal(manifest.dependencies['ds-bot'], `link:${join(pkgs, 'ds-bot-0.2.1')}`)
62+
assert.equal(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8'), '- id: flybot-social\n')
63+
assert.equal(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), 'packages:\n - .\n# mine\n')
64+
prepareProfile(home, pkgs)
65+
assert.deepEqual(JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')), manifest, 'a second start changes nothing')
66+
rmSync(join(profile, 'pnpm-workspace.yaml'))
67+
prepareProfile(home, pkgs)
68+
assert.match(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), /nodeLinker: hoisted\nautoInstallPeers: false/)
69+
} finally {
70+
rmSync(pkgs, { recursive: true, force: true })
71+
rmSync(home, { recursive: true, force: true })
72+
}
73+
})
74+
75+
test('secure defaults: loopback port, non-root user, no secret in the image, no registry push', () => {
76+
const compose = read('compose.yaml')
77+
assert.match(compose, /^ {6}- "127\.0\.0\.1:\$\{FLYBOT_PORT:-3080\}:3080"$/m, 'the port is published on the host loopback only')
78+
assert.match(compose, /^ {6}- data:\/data$/m, 'one data volume')
79+
assert.match(compose, /^ {6}- DEEPSEEK_API_KEY$/m, 'the key is passed through, never written into the file')
80+
const dockerfile = read('Dockerfile')
81+
assert.match(dockerfile, /^USER node$/m)
82+
assert.match(dockerfile, /^VOLUME \["\/data"\]$/m)
83+
assert.match(dockerfile, /^HEALTHCHECK /m)
84+
assert.doesNotMatch(dockerfile, /^(ENV|ARG)\s.*(KEY|TOKEN|SECRET)/im, 'no key in the image')
85+
const pinned = JSON.parse(read('core/dev/harness/package.json')).dependencies['@deepseek-ai/dsh']
86+
assert.match(dockerfile, new RegExp(`^ARG DSH_VERSION=${pinned.replace(/\./g, '\\.')}$`, 'm'), 'DSH is pinned like the tests and the README')
87+
const ignore = read('.dockerignore')
88+
for (const entry of ['**/.env', '**/.credentials.yaml', '**/.dsh*', '.git']) assert.ok(ignore.split('\n').includes(entry), `.dockerignore lists ${entry}`)
89+
const workflow = read('.github/workflows/docker.yml')
90+
assert.doesNotMatch(workflow, /docker (push|login)|docker\/(login|build-push)-action|packages: write/, 'CI builds and checks only')
91+
assert.match(read('docker/web.patch.yml'), /^ {4}host: 0\.0\.0\.0$/m)
92+
})

‎.github/workflows/docker.yml‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Container image check (#352): builds the image from this checkout and runs a start check
2+
# (compose up until the health check passes, the page answers on the host's loopback, the
3+
# process is not root). Nothing is pushed to a registry and nothing is deployed.
4+
name: Docker
5+
6+
on:
7+
pull_request:
8+
paths:
9+
- Dockerfile
10+
- compose.yaml
11+
- .dockerignore
12+
- docker/**
13+
- .github/scripts/check-pack.mjs
14+
- .github/workflows/docker.yml
15+
push:
16+
branches: [main]
17+
paths:
18+
- Dockerfile
19+
- compose.yaml
20+
- .dockerignore
21+
- docker/**
22+
- .github/scripts/check-pack.mjs
23+
- .github/workflows/docker.yml
24+
workflow_dispatch:
25+
26+
permissions:
27+
contents: read
28+
29+
concurrency:
30+
group: docker-${{ github.ref }}
31+
cancel-in-progress: true
32+
33+
jobs:
34+
image:
35+
name: build and start check
36+
runs-on: ubuntu-latest
37+
timeout-minutes: 20
38+
env:
39+
FLYBOT_TAG: ci
40+
FLYBOT_PORT: '3080'
41+
COMPOSE_PROJECT_NAME: flybot-ci
42+
steps:
43+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
44+
with:
45+
persist-credentials: false
46+
- name: Build the image
47+
run: docker compose build
48+
- name: Start and wait for the health check
49+
run: docker compose up -d --wait --wait-timeout 180
50+
- name: Start check
51+
run: |
52+
set -eu
53+
# Unauthenticated, the web server answers 401 on the host's loopback port.
54+
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$FLYBOT_PORT/")
55+
echo "GET / -> $code"
56+
[ "$code" = 401 ]
57+
# The sign-in link was printed (checked, never shown: it carries the token).
58+
docker compose logs flybot | grep -q 'dsh web: http://127.0.0.1:3080/?token='
59+
# Not root, and the FlyBot packages are linked from the image.
60+
uid=$(docker compose exec -T flybot id -u)
61+
echo "uid: $uid"
62+
[ "$uid" != 0 ]
63+
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(', '))"
64+
- name: Logs (sign-in token removed)
65+
if: always()
66+
run: docker compose logs flybot 2>&1 | sed -E 's/token=[^ &)]*/token=REDACTED/g' | tail -n 60
67+
- name: Stop
68+
if: always()
69+
run: docker compose down -v

‎Dockerfile‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# FlyBot in a container (#352): one person, one instance. `dsh web` with the FlyBot core,
2+
# social, tree and timeline packages of this checkout. Build it yourself; nothing is published.
3+
# docker compose up -d --build (see docs/docker.md)
4+
# No keys or tokens are baked in: DEEPSEEK_API_KEY comes from the environment or from the
5+
# credentials file on the data volume.
6+
7+
ARG NODE_IMAGE=node:24-bookworm-slim
8+
9+
# Stage 1: pack the packages exactly as a DSH profile installs them (the release tarballs).
10+
FROM ${NODE_IMAGE} AS pack
11+
WORKDIR /src
12+
COPY . .
13+
RUN node .github/scripts/check-pack.mjs /pkgs \
14+
&& for tgz in /pkgs/*.tgz; do \
15+
dir="/out/$(basename "$tgz" .tgz)"; mkdir -p "$dir" && tar -xzf "$tgz" -C "$dir" --strip-components=1; \
16+
done
17+
18+
# Stage 2: the runtime.
19+
FROM ${NODE_IMAGE}
20+
# DSH as pinned in the README; pnpm is what `dsh plugin` uses to manage the profile.
21+
ARG DSH_VERSION=0.2.0-rc.2
22+
ARG PNPM_VERSION=10.34.6
23+
RUN apt-get update \
24+
&& apt-get install -y --no-install-recommends ca-certificates git tini \
25+
&& rm -rf /var/lib/apt/lists/* \
26+
&& npm install -g --no-fund --no-audit "@deepseek-ai/dsh@${DSH_VERSION}" "pnpm@${PNPM_VERSION}" \
27+
&& npm cache clean --force
28+
COPY --from=pack /out/ /opt/flybot/packages/
29+
COPY docker/ /opt/flybot/docker/
30+
# The node image's unprivileged user (uid 1000). /data is the one volume: DSH home (profile,
31+
# Bots, Sessions, credentials), the pnpm store and the Bots' workspace.
32+
RUN mkdir -p /data && chown node:node /data
33+
ENV DSH_HOME=/data HOME=/data NODE_ENV=production
34+
USER node
35+
WORKDIR /data
36+
VOLUME ["/data"]
37+
EXPOSE 3080
38+
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD ["node", "/opt/flybot/docker/healthcheck.mjs"]
39+
ENTRYPOINT ["tini", "--", "node", "/opt/flybot/docker/entrypoint.mjs"]

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 web --port 3080
134134

135135
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…**).
136136

137+
To run FlyBot on a Linux server that stays on, one instance per person, use the container: [Run FlyBot in Docker](docs/docker.md).
138+
137139
### 4. Self-host Bot Social (for friends' Bots)
138140

139141
`@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:

‎README.zh.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 web --port 3080
129129

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

132+
想在一直开着的 Linux 服务器上运行(每人一个实例),可以用容器:[用 Docker 运行 FlyBot](docs/docker.zh.md)。
133+
132134
### 4. 自建 Bot Social(好友功能需要)
133135

134136
`@flybot/social` **依赖**你自己部署的 [Bot Social](https://github.com/Birfy/bot-social),仓库里不附带服务器。按 Bot Social 的 README 部署,并设置对外地址:

‎changelog.d/352-docker.added.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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)).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Settings → Connectors: pick Composio apps by searching Composio's app list instead of typing app names, and see for each picked app whether it is ready or still needs a sign-in ([#359](https://github.com/ThinkFlowLab/FlyBot/issues/359)).

‎compose.yaml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# FlyBot, one instance per person (#352). Guide: docs/docker.md (中文: docs/docker.zh.md).
2+
# docker compose up -d --build build this checkout and start
3+
# docker compose logs flybot | grep token= the sign-in link
4+
# More instances on one machine: a project name and a port each, for example
5+
# FLYBOT_PORT=3081 docker compose -p alice up -d --build
6+
services:
7+
flybot:
8+
build: .
9+
image: flybot:${FLYBOT_TAG:-local}
10+
restart: unless-stopped
11+
ports:
12+
# Host loopback only. For remote access use your own HTTPS proxy or an SSH tunnel;
13+
# never publish this port on a public address.
14+
- "127.0.0.1:${FLYBOT_PORT:-3080}:3080"
15+
environment:
16+
# Passed only when set in your shell or in .env next to this file; otherwise save the
17+
# key in the app (it is kept on the data volume).
18+
- DEEPSEEK_API_KEY
19+
volumes:
20+
- data:/data
21+
volumes:
22+
data:

0 commit comments

Comments
 (0)