@plosson/agentio 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/config-import.test.ts +330 -0
- package/src/commands/config.ts +21 -2
- package/src/commands/mcp.ts +5 -0
- package/src/commands/server-tokens.test.ts +269 -0
- package/src/commands/server.ts +514 -0
- package/src/commands/teleport.test.ts +1216 -0
- package/src/commands/teleport.ts +733 -0
- package/src/index.ts +2 -0
- package/src/mcp/server.test.ts +89 -0
- package/src/mcp/server.ts +51 -30
- package/src/server/daemon.test.ts +637 -0
- package/src/server/daemon.ts +177 -0
- package/src/server/dockerfile-gen.test.ts +192 -0
- package/src/server/dockerfile-gen.ts +101 -0
- package/src/server/dockerfile-teleport.test.ts +180 -0
- package/src/server/http.test.ts +256 -0
- package/src/server/http.ts +54 -0
- package/src/server/mcp-adversarial.test.ts +643 -0
- package/src/server/mcp-e2e.test.ts +397 -0
- package/src/server/mcp-http.test.ts +364 -0
- package/src/server/mcp-http.ts +339 -0
- package/src/server/oauth-e2e.test.ts +466 -0
- package/src/server/oauth-store.test.ts +423 -0
- package/src/server/oauth-store.ts +216 -0
- package/src/server/oauth.test.ts +1502 -0
- package/src/server/oauth.ts +800 -0
- package/src/server/siteio-runner.test.ts +720 -0
- package/src/server/siteio-runner.ts +329 -0
- package/src/server/test-helpers.ts +201 -0
- package/src/types/config.ts +3 -0
- package/src/types/server.ts +61 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
|
|
3
|
+
import { loadConfig, saveConfig } from '../config/config-manager';
|
|
4
|
+
import type { Config } from '../types/config';
|
|
5
|
+
import type { ServerConfig } from '../types/server';
|
|
6
|
+
import { handleRequest, type ServerContext } from './http';
|
|
7
|
+
import {
|
|
8
|
+
createOAuthStore,
|
|
9
|
+
type PersistableOAuthState,
|
|
10
|
+
} from './oauth-store';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PORT = 9999;
|
|
13
|
+
const DEFAULT_HOST = '0.0.0.0';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate a port value from any source (CLI, env, config). Bun.serve
|
|
17
|
+
* silently falls back to a default when given NaN / negative / oversized
|
|
18
|
+
* values; we want to bail loudly instead so the operator sees the
|
|
19
|
+
* misconfiguration.
|
|
20
|
+
*/
|
|
21
|
+
function validatePort(value: number, source: string): number {
|
|
22
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Invalid port from ${source}: must be an integer in [1, 65535], got ${value}`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StartServerOptions {
|
|
31
|
+
/** Override the bound port (CLI flag > env > config > default). */
|
|
32
|
+
port?: number;
|
|
33
|
+
/** Override the bound host (CLI flag > env > config > default). */
|
|
34
|
+
host?: string;
|
|
35
|
+
/** Override the API key in-memory (does not get persisted). */
|
|
36
|
+
apiKey?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let shutdownRequested = false;
|
|
40
|
+
let runningServer: ReturnType<typeof Bun.serve> | null = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve effective server config from CLI options → env vars → stored
|
|
44
|
+
* config → defaults. The stored config is the source of truth for the API
|
|
45
|
+
* key (it persists across restarts), but CLI/env can override it for the
|
|
46
|
+
* current process only.
|
|
47
|
+
*/
|
|
48
|
+
async function resolveServerConfig(opts: StartServerOptions): Promise<{
|
|
49
|
+
port: number;
|
|
50
|
+
host: string;
|
|
51
|
+
apiKey: string;
|
|
52
|
+
generated: boolean;
|
|
53
|
+
}> {
|
|
54
|
+
const config = (await loadConfig()) as Config;
|
|
55
|
+
let stored: ServerConfig = config.server ?? {};
|
|
56
|
+
let generated = false;
|
|
57
|
+
|
|
58
|
+
// Generate and persist an API key on first run.
|
|
59
|
+
if (!stored.apiKey) {
|
|
60
|
+
const generatedKey = `srv_${randomBytes(24).toString('base64url')}`;
|
|
61
|
+
stored = { ...stored, apiKey: generatedKey };
|
|
62
|
+
config.server = stored;
|
|
63
|
+
await saveConfig(config);
|
|
64
|
+
generated = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let port: number;
|
|
68
|
+
if (opts.port !== undefined) {
|
|
69
|
+
port = validatePort(opts.port, '--port');
|
|
70
|
+
} else if (process.env.AGENTIO_SERVER_PORT) {
|
|
71
|
+
port = validatePort(
|
|
72
|
+
Number(process.env.AGENTIO_SERVER_PORT),
|
|
73
|
+
'AGENTIO_SERVER_PORT'
|
|
74
|
+
);
|
|
75
|
+
} else if (stored.port !== undefined) {
|
|
76
|
+
port = validatePort(stored.port, 'config.server.port');
|
|
77
|
+
} else {
|
|
78
|
+
port = DEFAULT_PORT;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const host =
|
|
82
|
+
opts.host ??
|
|
83
|
+
process.env.AGENTIO_SERVER_HOST ??
|
|
84
|
+
stored.host ??
|
|
85
|
+
DEFAULT_HOST;
|
|
86
|
+
|
|
87
|
+
const apiKey =
|
|
88
|
+
opts.apiKey ?? process.env.AGENTIO_SERVER_API_KEY ?? stored.apiKey!;
|
|
89
|
+
|
|
90
|
+
return { port, host, apiKey, generated };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start the agentio HTTP server in the foreground. Mirrors the gateway
|
|
95
|
+
* daemon's lifecycle: load/persist config, install signal handlers, run a
|
|
96
|
+
* Bun HTTP server, and `await` forever until SIGINT/SIGTERM.
|
|
97
|
+
*/
|
|
98
|
+
export async function startServer(opts: StartServerOptions = {}): Promise<void> {
|
|
99
|
+
console.log(`agentio-server starting (PID ${process.pid})`);
|
|
100
|
+
|
|
101
|
+
const { port, host, apiKey, generated } = await resolveServerConfig(opts);
|
|
102
|
+
|
|
103
|
+
if (generated) {
|
|
104
|
+
console.log('Generated new API key (persisted to config.server.apiKey)');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Always print the API key so headless deploys (Docker, journalctl) can
|
|
108
|
+
// read it from the logs.
|
|
109
|
+
console.log(`API Key: ${apiKey}`);
|
|
110
|
+
console.log(`Listening on http://${host}:${port}`);
|
|
111
|
+
|
|
112
|
+
// Build the OAuth store. State is loaded from config.server.{clients,
|
|
113
|
+
// tokens} and persisted on every mutation through a callback that
|
|
114
|
+
// round-trips through loadConfig/saveConfig — that way we never clobber
|
|
115
|
+
// unrelated config.server.* fields (apiKey, port, host) that another
|
|
116
|
+
// part of the daemon may have updated.
|
|
117
|
+
const initialConfig = (await loadConfig()) as Config;
|
|
118
|
+
const oauthStore = createOAuthStore({
|
|
119
|
+
initial: {
|
|
120
|
+
clients: initialConfig.server?.clients,
|
|
121
|
+
tokens: initialConfig.server?.tokens,
|
|
122
|
+
},
|
|
123
|
+
save: async (state: PersistableOAuthState) => {
|
|
124
|
+
const cfg = (await loadConfig()) as Config;
|
|
125
|
+
cfg.server = {
|
|
126
|
+
...cfg.server,
|
|
127
|
+
clients: state.clients,
|
|
128
|
+
tokens: state.tokens,
|
|
129
|
+
};
|
|
130
|
+
await saveConfig(cfg);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const ctx: ServerContext = { apiKey, oauthStore };
|
|
135
|
+
|
|
136
|
+
const shutdown = async (signal: string) => {
|
|
137
|
+
if (shutdownRequested) return;
|
|
138
|
+
shutdownRequested = true;
|
|
139
|
+
console.log(`\nReceived ${signal}, shutting down...`);
|
|
140
|
+
if (runningServer) {
|
|
141
|
+
runningServer.stop();
|
|
142
|
+
runningServer = null;
|
|
143
|
+
}
|
|
144
|
+
console.log('Server stopped');
|
|
145
|
+
process.exit(0);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
149
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
runningServer = Bun.serve({
|
|
153
|
+
port,
|
|
154
|
+
hostname: host,
|
|
155
|
+
// MCP's Streamable HTTP transport keeps GET /mcp SSE streams open
|
|
156
|
+
// for server-initiated messages (notifications, progress updates),
|
|
157
|
+
// and some tool calls legitimately take more than the default 10s
|
|
158
|
+
// (Gmail search, large RSS feeds, JIRA JQL queries). Set the idle
|
|
159
|
+
// timeout to Bun's maximum so the transport can hold connections
|
|
160
|
+
// as long as it needs. 255 is Bun's hard cap — passing higher
|
|
161
|
+
// values throws.
|
|
162
|
+
idleTimeout: 255,
|
|
163
|
+
fetch: (req) => handleRequest(req, ctx),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
console.log('Server ready');
|
|
167
|
+
|
|
168
|
+
// Wait forever; signal handlers will exit().
|
|
169
|
+
await new Promise(() => {});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(
|
|
172
|
+
'Server error:',
|
|
173
|
+
error instanceof Error ? error.message : error
|
|
174
|
+
);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { generateTeleportDockerfile } from './dockerfile-gen';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pure unit tests for the teleport Dockerfile generator. These don't
|
|
7
|
+
* actually build a container; they just assert on the string that
|
|
8
|
+
* gets fed to siteio. The goal is to catch refactors that silently
|
|
9
|
+
* change container semantics — e.g. flipping the user to root, or
|
|
10
|
+
* dropping tini, or breaking the healthcheck.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
describe('generateTeleportDockerfile — structural invariants', () => {
|
|
14
|
+
test('is a non-empty string', () => {
|
|
15
|
+
const df = generateTeleportDockerfile();
|
|
16
|
+
expect(df).toBeTruthy();
|
|
17
|
+
expect(df.length).toBeGreaterThan(200);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('starts with a comment declaring it auto-generated', () => {
|
|
21
|
+
const df = generateTeleportDockerfile();
|
|
22
|
+
expect(df.split('\n')[0]).toMatch(/^#.*auto-generated.*agentio mcp teleport/i);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('uses ubuntu:24.04 as the base image', () => {
|
|
26
|
+
const df = generateTeleportDockerfile();
|
|
27
|
+
expect(df).toContain('FROM ubuntu:24.04');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('installs ca-certificates, curl, tini', () => {
|
|
31
|
+
const df = generateTeleportDockerfile();
|
|
32
|
+
expect(df).toContain('ca-certificates');
|
|
33
|
+
expect(df).toContain('curl');
|
|
34
|
+
expect(df).toContain('tini');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('cleans up apt lists (image size hygiene)', () => {
|
|
38
|
+
const df = generateTeleportDockerfile();
|
|
39
|
+
expect(df).toContain('rm -rf /var/lib/apt/lists/*');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('creates the non-root agentio user with uid 1001', () => {
|
|
43
|
+
const df = generateTeleportDockerfile();
|
|
44
|
+
expect(df).toContain('groupadd -g 1001 agentio');
|
|
45
|
+
expect(df).toContain('useradd -u 1001 -g agentio');
|
|
46
|
+
expect(df).toContain('USER agentio');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('sets HOME, XDG_CONFIG_HOME, PATH for the non-root user', () => {
|
|
50
|
+
const df = generateTeleportDockerfile();
|
|
51
|
+
expect(df).toContain('ENV HOME=/data');
|
|
52
|
+
expect(df).toContain('ENV XDG_CONFIG_HOME=/data');
|
|
53
|
+
expect(df).toContain('ENV PATH="/home/agentio/bin:${PATH}"');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('ensures /data and /home/agentio/bin are owned by agentio', () => {
|
|
57
|
+
const df = generateTeleportDockerfile();
|
|
58
|
+
expect(df).toContain('chown -R agentio:agentio /data /home/agentio/bin');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('never uses COPY or ADD (siteio inline Dockerfile constraint)', () => {
|
|
62
|
+
const df = generateTeleportDockerfile();
|
|
63
|
+
const lines = df.split('\n').filter((l) => !l.trim().startsWith('#'));
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
expect(line).not.toMatch(/^\s*COPY\b/);
|
|
66
|
+
expect(line).not.toMatch(/^\s*ADD\b/);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('generateTeleportDockerfile — binary fetch', () => {
|
|
72
|
+
test('fetches the binary at BUILD time via RUN curl (not at CMD)', () => {
|
|
73
|
+
const df = generateTeleportDockerfile();
|
|
74
|
+
// The curl call for the binary should be in a RUN, not in the CMD.
|
|
75
|
+
const cmdLine = df.match(/CMD \[.*\]/)?.[0] ?? '';
|
|
76
|
+
expect(cmdLine).not.toContain('curl -fL');
|
|
77
|
+
expect(df).toContain('curl -fL');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('resolves arch for both x86_64 and arm64 linux', () => {
|
|
81
|
+
const df = generateTeleportDockerfile();
|
|
82
|
+
expect(df).toContain('aarch64|arm64');
|
|
83
|
+
expect(df).toContain('x86_64|amd64');
|
|
84
|
+
expect(df).toContain('linux-arm64');
|
|
85
|
+
expect(df).toContain('linux-x64');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('unsupported arch exits 1 with a clear message', () => {
|
|
89
|
+
const df = generateTeleportDockerfile();
|
|
90
|
+
expect(df).toContain('unsupported arch');
|
|
91
|
+
expect(df).toContain('exit 1');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('no version pinned → queries GitHub releases/latest for the tag', () => {
|
|
95
|
+
const df = generateTeleportDockerfile();
|
|
96
|
+
expect(df).toContain(
|
|
97
|
+
'https://api.github.com/repos/plosson/agentio/releases/latest'
|
|
98
|
+
);
|
|
99
|
+
expect(df).toContain('tag_name');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('--version pinned → emits a literal VERSION="X" assignment', () => {
|
|
103
|
+
const df = generateTeleportDockerfile({ version: '1.2.3' });
|
|
104
|
+
expect(df).toContain('VERSION="1.2.3"');
|
|
105
|
+
expect(df).not.toContain('releases/latest');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('download URL references both VERSION and PLATFORM variables', () => {
|
|
109
|
+
const df = generateTeleportDockerfile();
|
|
110
|
+
expect(df).toContain(
|
|
111
|
+
'https://github.com/plosson/agentio/releases/download/v${VERSION}/agentio-${PLATFORM}'
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('chmod +x on the installed binary', () => {
|
|
116
|
+
const df = generateTeleportDockerfile();
|
|
117
|
+
expect(df).toContain('chmod +x /home/agentio/bin/agentio');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('generateTeleportDockerfile — port + healthcheck + entrypoint', () => {
|
|
122
|
+
test('default port is 9999', () => {
|
|
123
|
+
const df = generateTeleportDockerfile();
|
|
124
|
+
expect(df).toContain('EXPOSE 9999');
|
|
125
|
+
expect(df).toContain('http://localhost:9999/health');
|
|
126
|
+
expect(df).toContain('--port 9999');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('custom port threads through EXPOSE + HEALTHCHECK + CMD', () => {
|
|
130
|
+
const df = generateTeleportDockerfile({ port: 8080 });
|
|
131
|
+
expect(df).toContain('EXPOSE 8080');
|
|
132
|
+
expect(df).toContain('http://localhost:8080/health');
|
|
133
|
+
expect(df).toContain('--port 8080');
|
|
134
|
+
expect(df).not.toContain('EXPOSE 9999');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('HEALTHCHECK has 30s start-period (enough for config import)', () => {
|
|
138
|
+
const df = generateTeleportDockerfile();
|
|
139
|
+
expect(df).toContain('--start-period=30s');
|
|
140
|
+
expect(df).toContain('HEALTHCHECK');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('HEALTHCHECK uses curl -sf to exit non-zero on failure', () => {
|
|
144
|
+
const df = generateTeleportDockerfile();
|
|
145
|
+
expect(df).toContain('curl -sf http://localhost');
|
|
146
|
+
expect(df).toContain('exit 1');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('tini is PID 1 via ENTRYPOINT', () => {
|
|
150
|
+
const df = generateTeleportDockerfile();
|
|
151
|
+
expect(df).toContain('ENTRYPOINT ["/usr/bin/tini", "--"]');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('CMD runs config import THEN the server, via sh -c', () => {
|
|
155
|
+
const df = generateTeleportDockerfile();
|
|
156
|
+
expect(df).toContain(
|
|
157
|
+
'CMD ["sh", "-c", "agentio config import && exec agentio server start --foreground --host 0.0.0.0 --port 9999"]'
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('CMD uses `exec` before starting the server (so tini sees the right PID)', () => {
|
|
162
|
+
const df = generateTeleportDockerfile();
|
|
163
|
+
const cmdLine = df.match(/CMD \[.*\]/)?.[0] ?? '';
|
|
164
|
+
expect(cmdLine).toContain('exec agentio server start');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('CMD binds to 0.0.0.0 (required for Docker networking)', () => {
|
|
168
|
+
const df = generateTeleportDockerfile();
|
|
169
|
+
expect(df).toContain('--host 0.0.0.0');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('generateTeleportDockerfile — security posture', () => {
|
|
174
|
+
test('switches to non-root user BEFORE the CMD runs', () => {
|
|
175
|
+
const df = generateTeleportDockerfile();
|
|
176
|
+
const userIdx = df.indexOf('USER agentio');
|
|
177
|
+
const cmdIdx = df.search(/^CMD /m);
|
|
178
|
+
expect(userIdx).toBeGreaterThan(-1);
|
|
179
|
+
expect(cmdIdx).toBeGreaterThan(-1);
|
|
180
|
+
expect(userIdx).toBeLessThan(cmdIdx);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('does not install sudo', () => {
|
|
184
|
+
const df = generateTeleportDockerfile();
|
|
185
|
+
expect(df).not.toContain('sudo');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('does not expose an SSH port', () => {
|
|
189
|
+
const df = generateTeleportDockerfile();
|
|
190
|
+
expect(df).not.toContain('EXPOSE 22');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a self-contained inline Dockerfile for `agentio mcp teleport`.
|
|
3
|
+
*
|
|
4
|
+
* siteio's "inline Dockerfile" mode builds containers in an empty build
|
|
5
|
+
* context — the Dockerfile cannot `COPY` or `ADD` anything from the
|
|
6
|
+
* local filesystem. Everything the container needs has to be fetched at
|
|
7
|
+
* build time via `RUN curl`.
|
|
8
|
+
*
|
|
9
|
+
* This generator produces a Dockerfile that:
|
|
10
|
+
* 1. Installs ca-certificates, curl, and tini (for signal handling).
|
|
11
|
+
* 2. Creates a non-root `agentio` user with /data as HOME.
|
|
12
|
+
* 3. Fetches the release binary for the target platform at BUILD time,
|
|
13
|
+
* so siteio/Docker can cache the layer and re-deploys are fast.
|
|
14
|
+
* 4. Runs `agentio config import` at container START, reading the
|
|
15
|
+
* encrypted blob from the `AGENTIO_KEY` + `AGENTIO_CONFIG` env
|
|
16
|
+
* vars (set by siteio via `apps set -e`).
|
|
17
|
+
* 5. Starts the HTTP server on port 9999, bound to 0.0.0.0.
|
|
18
|
+
*
|
|
19
|
+
* The generator is a pure function — no I/O, no side effects — so it's
|
|
20
|
+
* trivially unit-testable. Tests in dockerfile-gen.test.ts pin down the
|
|
21
|
+
* exact structure (HEALTHCHECK, EXPOSE, user, entrypoint, etc) so a
|
|
22
|
+
* well-meaning refactor can't silently break the container image.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface TeleportDockerfileOptions {
|
|
26
|
+
/** GitHub release version tag to pin, without the leading "v". Omit to always fetch "latest". */
|
|
27
|
+
version?: string;
|
|
28
|
+
/** Port the server should bind inside the container. Defaults to 9999. */
|
|
29
|
+
port?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_PORT = 9999;
|
|
33
|
+
|
|
34
|
+
export function generateTeleportDockerfile(
|
|
35
|
+
opts: TeleportDockerfileOptions = {}
|
|
36
|
+
): string {
|
|
37
|
+
const port = opts.port ?? DEFAULT_PORT;
|
|
38
|
+
// Inject a fixed version into the RUN block if the caller pinned one;
|
|
39
|
+
// otherwise fall through to the GitHub "latest" redirect.
|
|
40
|
+
const versionResolver = opts.version
|
|
41
|
+
? `VERSION="${opts.version}"`
|
|
42
|
+
: `VERSION=$(curl -sL https://api.github.com/repos/plosson/agentio/releases/latest | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\\1/')`;
|
|
43
|
+
|
|
44
|
+
return `# Auto-generated by \`agentio mcp teleport\`. Do not hand-edit.
|
|
45
|
+
# This is a self-contained "inline Dockerfile" for siteio — it must not
|
|
46
|
+
# reference any files from a local build context. Everything is fetched
|
|
47
|
+
# at build time via curl.
|
|
48
|
+
|
|
49
|
+
FROM ubuntu:24.04
|
|
50
|
+
|
|
51
|
+
# Runtime dependencies:
|
|
52
|
+
# ca-certificates : HTTPS calls from the container
|
|
53
|
+
# curl : release binary download + healthcheck
|
|
54
|
+
# tini : proper PID 1 / signal handling
|
|
55
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
|
56
|
+
ca-certificates curl tini \\
|
|
57
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
58
|
+
|
|
59
|
+
# Non-root user, home at /data so config.json + tokens.enc live in
|
|
60
|
+
# a siteio-managed persistent volume path.
|
|
61
|
+
RUN groupadd -g 1001 agentio \\
|
|
62
|
+
&& useradd -u 1001 -g agentio -d /home/agentio -m agentio \\
|
|
63
|
+
&& mkdir -p /data /home/agentio/bin \\
|
|
64
|
+
&& chown -R agentio:agentio /data /home/agentio/bin
|
|
65
|
+
|
|
66
|
+
# Fetch the agentio linux binary at BUILD time (not boot) so siteio
|
|
67
|
+
# caches the layer and subsequent deploys reuse it unless --no-cache
|
|
68
|
+
# is passed. Architecture is detected at build time.
|
|
69
|
+
RUN set -eux; \\
|
|
70
|
+
ARCH=$(uname -m); \\
|
|
71
|
+
case "$ARCH" in \\
|
|
72
|
+
aarch64|arm64) PLATFORM="linux-arm64" ;; \\
|
|
73
|
+
x86_64|amd64) PLATFORM="linux-x64" ;; \\
|
|
74
|
+
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \\
|
|
75
|
+
esac; \\
|
|
76
|
+
${versionResolver}; \\
|
|
77
|
+
echo "Installing agentio v\${VERSION} (\${PLATFORM})..."; \\
|
|
78
|
+
curl -fL "https://github.com/plosson/agentio/releases/download/v\${VERSION}/agentio-\${PLATFORM}" \\
|
|
79
|
+
-o /home/agentio/bin/agentio; \\
|
|
80
|
+
chmod +x /home/agentio/bin/agentio; \\
|
|
81
|
+
chown agentio:agentio /home/agentio/bin/agentio
|
|
82
|
+
|
|
83
|
+
USER agentio
|
|
84
|
+
ENV HOME=/data
|
|
85
|
+
ENV XDG_CONFIG_HOME=/data
|
|
86
|
+
ENV PATH="/home/agentio/bin:\${PATH}"
|
|
87
|
+
|
|
88
|
+
EXPOSE ${port}
|
|
89
|
+
|
|
90
|
+
# Healthcheck hits the agentio /health endpoint. start-period is 30s
|
|
91
|
+
# because importing the config on first boot involves decrypting the
|
|
92
|
+
# AGENTIO_CONFIG blob which can take a couple seconds on slow hardware.
|
|
93
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \\
|
|
94
|
+
CMD curl -sf http://localhost:${port}/health || exit 1
|
|
95
|
+
|
|
96
|
+
# tini becomes PID 1 for signal handling. sh -c runs the import-then-
|
|
97
|
+
# server pipeline so SIGTERM propagates correctly to the running server.
|
|
98
|
+
ENTRYPOINT ["/usr/bin/tini", "--"]
|
|
99
|
+
CMD ["sh", "-c", "agentio config import && exec agentio server start --foreground --host 0.0.0.0 --port ${port}"]
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Structural invariants for the committed docker/Dockerfile.teleport —
|
|
7
|
+
* the buildable-from-source Dockerfile used by `agentio teleport --git-branch`.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the inline Dockerfile (which siteio builds in an empty context
|
|
10
|
+
* and therefore cannot COPY), this one IS given the repo as a build
|
|
11
|
+
* context by siteio's git-mode, so COPY is legitimate. The tests
|
|
12
|
+
* below pin down the multi-stage structure, the non-root runtime,
|
|
13
|
+
* the config-import + server-start CMD shape, and a handful of security
|
|
14
|
+
* posture invariants.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DOCKERFILE_PATH = join(
|
|
18
|
+
import.meta.dir,
|
|
19
|
+
'..',
|
|
20
|
+
'..',
|
|
21
|
+
'docker',
|
|
22
|
+
'Dockerfile.teleport'
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function loadDockerfile(): string {
|
|
26
|
+
return readFileSync(DOCKERFILE_PATH, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('docker/Dockerfile.teleport — structural invariants', () => {
|
|
30
|
+
test('exists and is non-empty', () => {
|
|
31
|
+
const df = loadDockerfile();
|
|
32
|
+
expect(df.length).toBeGreaterThan(200);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('first content line documents it as the teleport Dockerfile', () => {
|
|
36
|
+
const df = loadDockerfile();
|
|
37
|
+
const firstComment = df
|
|
38
|
+
.split('\n')
|
|
39
|
+
.find((l) => l.trim().startsWith('#') && l.length > 1);
|
|
40
|
+
expect(firstComment).toMatch(/teleport/i);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('uses a multi-stage build (builder + runtime)', () => {
|
|
44
|
+
const df = loadDockerfile();
|
|
45
|
+
const fromLines = df
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter((l) => /^\s*FROM\s/.test(l));
|
|
48
|
+
expect(fromLines.length).toBe(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('stage 1 builds with oven/bun', () => {
|
|
52
|
+
const df = loadDockerfile();
|
|
53
|
+
expect(df).toMatch(/FROM\s+oven\/bun/);
|
|
54
|
+
expect(df).toMatch(/AS\s+builder/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('stage 1 runs bun install with --frozen-lockfile', () => {
|
|
58
|
+
const df = loadDockerfile();
|
|
59
|
+
expect(df).toContain('bun install --frozen-lockfile');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('stage 1 copies package.json + bun.lock separately from the rest', () => {
|
|
63
|
+
const df = loadDockerfile();
|
|
64
|
+
// This layer-caching discipline matters — we want bun install to
|
|
65
|
+
// only re-run when deps change, not on every source edit.
|
|
66
|
+
expect(df).toMatch(/COPY\s+package\.json\s+bun\.lock\*?\s+\.\//);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('stage 1 compiles the native binary via build:native', () => {
|
|
70
|
+
const df = loadDockerfile();
|
|
71
|
+
expect(df).toContain('bun run build:native');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('stage 2 is ubuntu:24.04', () => {
|
|
75
|
+
const df = loadDockerfile();
|
|
76
|
+
expect(df).toMatch(/FROM\s+ubuntu:24\.04\s*(?:\n|$)/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('stage 2 installs ca-certificates, curl, tini', () => {
|
|
80
|
+
const df = loadDockerfile();
|
|
81
|
+
expect(df).toContain('ca-certificates');
|
|
82
|
+
expect(df).toContain('curl');
|
|
83
|
+
expect(df).toContain('tini');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('stage 2 cleans up apt lists', () => {
|
|
87
|
+
const df = loadDockerfile();
|
|
88
|
+
expect(df).toContain('rm -rf /var/lib/apt/lists/*');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('stage 2 creates non-root agentio user with uid 1001', () => {
|
|
92
|
+
const df = loadDockerfile();
|
|
93
|
+
expect(df).toContain('groupadd -g 1001 agentio');
|
|
94
|
+
expect(df).toContain('useradd -u 1001 -g agentio');
|
|
95
|
+
expect(df).toContain('USER agentio');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('copies binary from stage 1 with --chown=agentio:agentio', () => {
|
|
99
|
+
const df = loadDockerfile();
|
|
100
|
+
expect(df).toMatch(
|
|
101
|
+
/COPY\s+--from=builder\s+--chown=agentio:agentio\s+\/build\/dist\/agentio\s+\/home\/agentio\/bin\/agentio/
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('sets HOME, XDG_CONFIG_HOME, PATH', () => {
|
|
106
|
+
const df = loadDockerfile();
|
|
107
|
+
expect(df).toContain('ENV HOME=/data');
|
|
108
|
+
expect(df).toContain('ENV XDG_CONFIG_HOME=/data');
|
|
109
|
+
expect(df).toMatch(/ENV PATH="\/home\/agentio\/bin:\$\{PATH\}"/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('exposes port 9999', () => {
|
|
113
|
+
const df = loadDockerfile();
|
|
114
|
+
expect(df).toContain('EXPOSE 9999');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('HEALTHCHECK probes /health on 9999 with curl -sf', () => {
|
|
118
|
+
const df = loadDockerfile();
|
|
119
|
+
expect(df).toContain('HEALTHCHECK');
|
|
120
|
+
expect(df).toContain('curl -sf http://localhost:9999/health');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('HEALTHCHECK has start-period ≥ 30s', () => {
|
|
124
|
+
const df = loadDockerfile();
|
|
125
|
+
expect(df).toMatch(/--start-period=(\d+)s/);
|
|
126
|
+
const match = df.match(/--start-period=(\d+)s/);
|
|
127
|
+
const seconds = Number(match![1]);
|
|
128
|
+
expect(seconds).toBeGreaterThanOrEqual(30);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('tini is PID 1 via ENTRYPOINT', () => {
|
|
132
|
+
const df = loadDockerfile();
|
|
133
|
+
expect(df).toContain('ENTRYPOINT ["/usr/bin/tini", "--"]');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('CMD runs config import THEN the server, via sh -c with exec', () => {
|
|
137
|
+
const df = loadDockerfile();
|
|
138
|
+
expect(df).toContain(
|
|
139
|
+
'CMD ["sh", "-c", "agentio config import && exec agentio server start --foreground --host 0.0.0.0 --port 9999"]'
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('CMD binds to 0.0.0.0 (required for Docker networking)', () => {
|
|
144
|
+
const df = loadDockerfile();
|
|
145
|
+
const cmdLine = df.match(/CMD \[.*\]/m)?.[0] ?? '';
|
|
146
|
+
expect(cmdLine).toContain('--host 0.0.0.0');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('docker/Dockerfile.teleport — security posture', () => {
|
|
151
|
+
test('runtime stage switches to USER agentio before the CMD runs', () => {
|
|
152
|
+
const df = loadDockerfile();
|
|
153
|
+
// Find the position of USER agentio and the FINAL CMD/ENTRYPOINT.
|
|
154
|
+
const userIdx = df.lastIndexOf('USER agentio');
|
|
155
|
+
const cmdIdx = df.search(/^CMD /m);
|
|
156
|
+
expect(userIdx).toBeGreaterThan(-1);
|
|
157
|
+
expect(cmdIdx).toBeGreaterThan(-1);
|
|
158
|
+
expect(userIdx).toBeLessThan(cmdIdx);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('does not install sudo', () => {
|
|
162
|
+
const df = loadDockerfile();
|
|
163
|
+
expect(df).not.toContain('sudo');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('does not EXPOSE SSH', () => {
|
|
167
|
+
const df = loadDockerfile();
|
|
168
|
+
expect(df).not.toContain('EXPOSE 22');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('HEALTHCHECK runs under the agentio user (no root escalation)', () => {
|
|
172
|
+
// Since USER agentio is set before HEALTHCHECK and CMD, they both
|
|
173
|
+
// run as agentio. Verify by ordering: USER must come before the
|
|
174
|
+
// HEALTHCHECK directive.
|
|
175
|
+
const df = loadDockerfile();
|
|
176
|
+
const userIdx = df.lastIndexOf('USER agentio');
|
|
177
|
+
const hcIdx = df.indexOf('HEALTHCHECK');
|
|
178
|
+
expect(userIdx).toBeLessThan(hcIdx);
|
|
179
|
+
});
|
|
180
|
+
});
|