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
14 changes: 13 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ directory's `auth.ts`, with `LOUSHO_API_TOKEN` appended to it: see
| Value | Store |
| ----- | ----- |
| `memory` (default) | `memoryStore()`: gone when the process restarts, and not shared between instances. |
| `sqlite:<path>` | A [`SqliteStore`](./sessions.md) file (created with its directory): sessions survive restarts. Needs Node 22 (`node:sqlite`); the `docker` image uses `node:22-slim`. Mount the file's directory as a volume. |
| `sqlite:<path>` | A [`SqliteStore`](./sessions.md) file (created with its directory): sessions survive restarts. Needs Node 22 (`node:sqlite`); the `docker` image uses `node:22-slim`. Mount the file's directory as a volume (the `docker` image owns `/app/data` for this). |

SQLite is one file on one disk, so run a single instance per database file.

Expand Down Expand Up @@ -312,6 +312,18 @@ docker build -t lousho-agent .
docker run -p 3000:3000 -e OPENAI_API_KEY=... lousho-agent
```

The image creates `/app/data`, owned by `node`, and declares it a `VOLUME`. Docker
copies that ownership into a fresh named volume, so the unprivileged server can write
there without a manual `chown`. Keep sessions across restarts by mounting a volume and
pointing the store at it:

```bash
docker run -p 3000:3000 -e OPENAI_API_KEY=... -v lousho-data:/app/data -e LOUSHO_STORE=sqlite:/app/data/lousho.db lousho-agent
```

A bind mount (`-v ./data:/app/data`) keeps the host directory's own ownership, so
`chown` it to uid 1000 first.

## `cloudflare-worker`

Generates a module Worker (`export default { fetch }`), a `wrangler.toml` and `dist/worker.js`, bundled as a
Expand Down
14 changes: 13 additions & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7265,7 +7265,7 @@ directory's `auth.ts`, with `LOUSHO_API_TOKEN` appended to it: see
| Value | Store |
| ----- | ----- |
| `memory` (default) | `memoryStore()`: gone when the process restarts, and not shared between instances. |
| `sqlite:<path>` | A [`SqliteStore`](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) file (created with its directory): sessions survive restarts. Needs Node 22 (`node:sqlite`); the `docker` image uses `node:22-slim`. Mount the file's directory as a volume. |
| `sqlite:<path>` | A [`SqliteStore`](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) file (created with its directory): sessions survive restarts. Needs Node 22 (`node:sqlite`); the `docker` image uses `node:22-slim`. Mount the file's directory as a volume (the `docker` image owns `/app/data` for this). |

SQLite is one file on one disk, so run a single instance per database file.

Expand Down Expand Up @@ -7363,6 +7363,18 @@ docker build -t lousho-agent .
docker run -p 3000:3000 -e OPENAI_API_KEY=... lousho-agent
```

The image creates `/app/data`, owned by `node`, and declares it a `VOLUME`. Docker
copies that ownership into a fresh named volume, so the unprivileged server can write
there without a manual `chown`. Keep sessions across restarts by mounting a volume and
pointing the store at it:

```bash
docker run -p 3000:3000 -e OPENAI_API_KEY=... -v lousho-data:/app/data -e LOUSHO_STORE=sqlite:/app/data/lousho.db lousho-agent
```

A bind mount (`-v ./data:/app/data`) keeps the host directory's own ownership, so
`chown` it to uid 1000 first.

## `cloudflare-worker`

Generates a module Worker (`export default { fetch }`), a `wrangler.toml` and `dist/worker.js`, bundled as a
Expand Down
40 changes: 40 additions & 0 deletions src/deploy/adapters/docker.docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,44 @@ describe.skipIf(!hasDocker)('DockerAdapter image (real Docker daemon)', () => {
dockerOutput(['rmi', '-f', tag]);
}
}, 300_000);

it('starts on a fresh named volume with LOUSHO_STORE=sqlite: and persists a session', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lousho-docker-vol-'));
const outDir = path.join(dir, 'out');
await DockerAdapter.scaffold(writeSpec(dir), outDir);
await withBuildLock(() => DockerAdapter.build(outDir));

const stamp = Date.now();
const tag = `lousho-agent-vol-test-${stamp}`;
const volume = `lousho-vol-test-${stamp}`;
execFileSync('docker', ['build', '-t', tag, '.'], { cwd: outDir, stdio: 'inherit' });
execFileSync('docker', ['volume', 'create', volume]);
const containerId = execFileSync(
'docker',
['run', '-d', '-p', '127.0.0.1::3000', '-v', `${volume}:/app/data`, '-e', 'LOUSHO_STORE=sqlite:/app/data/lousho.db', tag],
{ encoding: 'utf8' }
).trim();
try {
const mapping = execFileSync('docker', ['port', containerId, '3000'], { encoding: 'utf8' });
const base = `http://127.0.0.1:${Number(mapping.trim().split('\n')[0].split(':').pop())}`;
const health = await poll(`${base}/health`, 60);
if (health?.status !== 200) console.error(`docker logs:
${dockerOutput(['logs', containerId])}`);
expect(health?.status).toBe(200);

const chat = await fetch(`${base}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'hello volume', sessionId: 'vol-1' }),
});
expect(chat.status).toBe(200);
// The database file landed on the volume, written by the unprivileged user.
const ls = execFileSync('docker', ['exec', containerId, 'ls', '-l', '/app/data'], { encoding: 'utf8' });
expect(ls).toMatch(/lousho\.db/);
} finally {
dockerOutput(['rm', '-f', containerId]);
dockerOutput(['rmi', '-f', tag]);
dockerOutput(['volume', 'rm', '-f', volume]);
}
}, 300_000);
});
8 changes: 8 additions & 0 deletions src/deploy/adapters/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ describe('DockerAdapter', () => {
expect(lines).toContain('ENV HOST=0.0.0.0');
});

it('creates and chowns /app/data before dropping to the node user, so a fresh named volume is writable', () => {
const lines = DOCKERFILE.split('\n');
const mkdir = lines.findIndex((l) => /^RUN mkdir -p \/app\/data && chown node:node \/app\/data$/.test(l));
expect(mkdir).toBeGreaterThan(-1);
expect(mkdir).toBeLessThan(lines.indexOf('USER node'));
expect(lines).toContain('VOLUME /app/data');
});

it('writes a package.json shipping the SDK and the peers the spec needs', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lousho-docker-'));
const specPath = path.join(dir, 'agent.yaml');
Expand Down
7 changes: 7 additions & 0 deletions src/deploy/adapters/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* dist/server.js), and scaffold() additionally writes a Dockerfile (plus a
* .dockerignore) that packages dist/ into a node:22-slim image (22 has the node:sqlite that LOUSHO_STORE=sqlite:<path> needs).
* describe(): 'docker build -t lousho-agent . && docker run -p 3000:3000 lousho-agent'.
* The image also owns /app/data (declared a VOLUME) for LOUSHO_STORE=sqlite:/app/data/<file>.
*
* The image sets HOST=0.0.0.0: inside a container, the server's default
* 127.0.0.1 binding would be unreachable through `docker run -p`, so the
Expand All @@ -26,6 +27,12 @@ COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi && npm cache clean --force
COPY dist/ ./dist/
ENV NODE_ENV=production
# Docker copies an image directory's ownership into a fresh named volume, so the
# data directory must exist and belong to node before USER node and VOLUME, or
# \`-v lousho-data:/app/data\` mounts a root-owned dir the server cannot write to.
# Point LOUSHO_STORE at it, e.g. sqlite:/app/data/lousho.db.
RUN mkdir -p /app/data && chown node:node /app/data
VOLUME /app/data
# Run as the image's unprivileged node user, not root.
USER node
# Listen on all interfaces inside the container so \`docker run -p\` can reach it.
Expand Down
Loading