@plosson/agentio 0.7.5 → 0.8.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -394,21 +394,44 @@ describe('runTeleport — preflight failures', () => {
394
394
  );
395
395
  });
396
396
 
397
- test('app already exists → CliError + warning', async () => {
397
+ test('app already exists → rebuild in place (no create, key preserved)', async () => {
398
398
  const deps = makeDeps({
399
399
  existingApp: { name: 'mcp', url: 'https://mcp.existing.com' },
400
400
  });
401
- await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow(
402
- /already exists/
403
- );
404
- // A warning was emitted before the throw so the user has context.
405
- expect(deps.warnLines.some((l) => l.includes('siteio apps rm'))).toBe(
406
- true
407
- );
408
- // Must not have attempted to create/set/deploy.
401
+ const result = await runTeleport({ name: 'mcp' }, deps);
402
+
409
403
  const methods = deps.calls.map((c) => c.method);
404
+ // Rebuild MUST NOT create the app — it already exists.
410
405
  expect(methods).not.toContain('createApp');
411
- expect(methods).not.toContain('deploy');
406
+ // But it MUST push env + redeploy so the image is rebuilt.
407
+ expect(methods).toContain('setApp');
408
+ expect(methods).toContain('deploy');
409
+
410
+ // setApp on rebuild preserves AGENTIO_SERVER_API_KEY by omitting it
411
+ // from the env map (siteio merges env vars).
412
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
413
+ expect(setCall).toBeDefined();
414
+ const envVars = (setCall!.args as { envVars?: Record<string, string> })
415
+ .envVars;
416
+ expect(envVars).toBeDefined();
417
+ expect(Object.keys(envVars!).sort()).toEqual([
418
+ 'AGENTIO_CONFIG',
419
+ 'AGENTIO_KEY',
420
+ ]);
421
+ expect(envVars!.AGENTIO_SERVER_API_KEY).toBeUndefined();
422
+
423
+ // Result signals a rebuild: no new server API key emitted.
424
+ expect(result.serverApiKey).toBe('');
425
+
426
+ // Log messages surface the rebuild intent, and the success banner
427
+ // is distinct from a fresh deploy.
428
+ const logs = deps.logLines.join('\n');
429
+ expect(logs).toMatch(/rebuild/i);
430
+ expect(logs).toContain('Rebuild complete!');
431
+ expect(logs).not.toContain('Teleport complete!');
432
+ // We do NOT print the onboarding snippet on rebuild — clients
433
+ // already have their bearer.
434
+ expect(logs).not.toContain('To add to Claude Code:');
412
435
  });
413
436
  });
414
437
 
@@ -513,27 +513,52 @@ export async function runTeleport(
513
513
  }
514
514
  deps.log(`Found ${profileCount} local profile(s).`);
515
515
 
516
- // App must not already exist.
516
+ // Check whether the app already exists on siteio. If it does, we
517
+ // REBUILD in place: skip createApp, preserve the existing
518
+ // AGENTIO_SERVER_API_KEY (so Claude clients keep their /authorize PIN
519
+ // and their issued bearers), backfill /data if needed, and redeploy
520
+ // the freshly generated image. If it doesn't exist, fall through to
521
+ // the fresh-deploy path.
517
522
  deps.log(`Checking if siteio app "${opts.name}" already exists…`);
518
523
  const existing = await deps.runner.findApp(opts.name);
519
- if (existing) {
520
- deps.warn(
521
- `A siteio app named "${opts.name}" already exists. ` +
522
- `Run \`siteio apps rm ${opts.name}\` if you want to redeploy from scratch.`
524
+ const isRebuild = Boolean(existing);
525
+ if (isRebuild) {
526
+ deps.log(
527
+ `Found existing siteio app "${opts.name}" will rebuild image in place (API key and clients preserved).`
523
528
  );
524
- throw new CliError(
525
- 'INVALID_PARAMS',
526
- `App "${opts.name}" already exists on siteio`
529
+ } else {
530
+ deps.log(
531
+ `No existing siteio app "${opts.name}" will create a fresh one.`
527
532
  );
528
533
  }
529
534
 
530
- // Generate a fresh server API key for the remote.
531
- const serverApiKey = deps.generateServerApiKey();
535
+ // Only generate a new operator API key on fresh deploys. On rebuild
536
+ // the remote's AGENTIO_SERVER_API_KEY is left untouched (siteio's
537
+ // `apps set -e` merges env vars, so omitting a key preserves it).
538
+ const serverApiKey = isRebuild ? '' : deps.generateServerApiKey();
532
539
 
533
- // Export the local config.
534
- deps.log('Exporting local configuration…');
540
+ // Export the local config (always — we want rebuild to also pick up
541
+ // any profile additions since the last deploy).
542
+ deps.log(
543
+ isRebuild
544
+ ? 'Re-exporting local configuration…'
545
+ : 'Exporting local configuration…'
546
+ );
535
547
  const exported = await deps.generateExportData();
536
548
 
549
+ // On rebuild, detect whether /data is already mounted so we backfill
550
+ // the persistent volume if it's missing (same logic as --sync).
551
+ let needsVolumeBackfill = false;
552
+ if (isRebuild) {
553
+ const detail = await deps.runner.appInfo(opts.name);
554
+ needsVolumeBackfill = !hasDataVolumeMount(detail);
555
+ if (needsVolumeBackfill) {
556
+ deps.log(
557
+ `No persistent volume mounted at ${DATA_VOLUME_PATH} — will attach ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH} as part of this rebuild.`
558
+ );
559
+ }
560
+ }
561
+
537
562
  // Resolve git mode settings up front so dry-run can show the same
538
563
  // command shape the real run would use.
539
564
  const isGitMode = Boolean(opts.gitBranch);
@@ -562,21 +587,37 @@ export async function runTeleport(
562
587
  // Dry-run: report what would happen and exit.
563
588
  if (opts.dryRun) {
564
589
  deps.log('--- Dry run: the following commands would be executed ---');
565
- if (gitSettings) {
566
- deps.log(
567
- `siteio apps create ${opts.name} -g ${gitSettings.repoUrl} --branch ${gitSettings.branch} --dockerfile ${TELEPORT_DOCKERFILE_PATH} -p 9999`
568
- );
569
- } else {
570
- deps.log(
571
- `siteio apps create ${opts.name} -f <tempfile> -p 9999`
572
- );
590
+ if (!isRebuild) {
591
+ if (gitSettings) {
592
+ deps.log(
593
+ `siteio apps create ${opts.name} -g ${gitSettings.repoUrl} --branch ${gitSettings.branch} --dockerfile ${TELEPORT_DOCKERFILE_PATH} -p 9999`
594
+ );
595
+ } else {
596
+ deps.log(
597
+ `siteio apps create ${opts.name} -f <tempfile> -p 9999`
598
+ );
599
+ }
573
600
  }
574
- deps.log(
575
- `siteio apps set ${opts.name} -e AGENTIO_KEY=<redacted> -e AGENTIO_CONFIG=<${exported.config.length} chars> -e AGENTIO_SERVER_API_KEY=${serverApiKey}`
576
- );
601
+ const setParts = [
602
+ `siteio apps set ${opts.name}`,
603
+ '-e AGENTIO_KEY=<redacted>',
604
+ `-e AGENTIO_CONFIG=<${exported.config.length} chars>`,
605
+ ];
606
+ if (!isRebuild) {
607
+ setParts.push(`-e AGENTIO_SERVER_API_KEY=${serverApiKey}`);
608
+ }
609
+ if (!isRebuild || needsVolumeBackfill) {
610
+ setParts.push(`-v ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH}`);
611
+ }
612
+ deps.log(setParts.join(' '));
577
613
  deps.log(
578
614
  `siteio apps deploy ${opts.name}${opts.noCache ? ' --no-cache' : ''}`
579
615
  );
616
+ if (isRebuild) {
617
+ deps.log(
618
+ '(AGENTIO_SERVER_API_KEY is intentionally NOT touched — operator key on the remote stays the same.)'
619
+ );
620
+ }
580
621
  if (!gitSettings) {
581
622
  const dockerfile = deps.generateDockerfile();
582
623
  deps.log('--- Dockerfile that would be uploaded ---');
@@ -601,41 +642,62 @@ export async function runTeleport(
601
642
  : await deps.writeTempFile(deps.generateDockerfile());
602
643
 
603
644
  try {
604
- deps.log(`Creating siteio app "${opts.name}"…`);
605
- if (gitSettings) {
606
- await deps.runner.createApp({
607
- name: opts.name,
608
- port: 9999,
609
- git: {
610
- repoUrl: gitSettings.repoUrl,
611
- branch: gitSettings.branch,
612
- dockerfilePath: TELEPORT_DOCKERFILE_PATH,
613
- },
614
- });
645
+ if (!isRebuild) {
646
+ deps.log(`Creating siteio app "${opts.name}"…`);
647
+ if (gitSettings) {
648
+ await deps.runner.createApp({
649
+ name: opts.name,
650
+ port: 9999,
651
+ git: {
652
+ repoUrl: gitSettings.repoUrl,
653
+ branch: gitSettings.branch,
654
+ dockerfilePath: TELEPORT_DOCKERFILE_PATH,
655
+ },
656
+ });
657
+ } else {
658
+ await deps.runner.createApp({
659
+ name: opts.name,
660
+ dockerfilePath: tempPath!,
661
+ port: 9999,
662
+ });
663
+ }
664
+ }
665
+
666
+ // On rebuild, omit AGENTIO_SERVER_API_KEY so siteio's env-merge
667
+ // preserves the existing value (clients keep their bearer). Only
668
+ // attach /data when not already mounted — siteio REPLACES the
669
+ // volumes list on update, so blindly passing it would clobber
670
+ // other mounts the operator added.
671
+ const envVars: Record<string, string> = {
672
+ AGENTIO_KEY: exported.key,
673
+ AGENTIO_CONFIG: exported.config,
674
+ ...(isRebuild ? {} : { AGENTIO_SERVER_API_KEY: serverApiKey }),
675
+ };
676
+ const attachVolume = !isRebuild || needsVolumeBackfill;
677
+
678
+ if (isRebuild) {
679
+ deps.log(
680
+ attachVolume
681
+ ? 'Updating env vars + attaching persistent volume on siteio…'
682
+ : 'Updating environment variables on siteio…'
683
+ );
615
684
  } else {
616
- await deps.runner.createApp({
617
- name: opts.name,
618
- dockerfilePath: tempPath!,
619
- port: 9999,
620
- });
685
+ deps.log('Setting environment variables and persistent volume…');
621
686
  }
622
687
 
623
- deps.log('Setting environment variables and persistent volume…');
624
688
  await deps.runner.setApp({
625
689
  name: opts.name,
626
- envVars: {
627
- AGENTIO_KEY: exported.key,
628
- AGENTIO_CONFIG: exported.config,
629
- AGENTIO_SERVER_API_KEY: serverApiKey,
630
- },
631
- // Persistent named volume mounted at /data so config.server.tokens
632
- // (issued OAuth bearers) survive container restarts. Without this
633
- // mount, every restart wipes the bearer and connected clients
634
- // would re-run the OAuth flow.
635
- volumes: { [volumeNameFor(opts.name)]: DATA_VOLUME_PATH },
690
+ envVars,
691
+ ...(attachVolume
692
+ ? { volumes: { [volumeNameFor(opts.name)]: DATA_VOLUME_PATH } }
693
+ : {}),
636
694
  });
637
695
 
638
- deps.log('Deploying (this may take a minute — Docker is building your image)…');
696
+ deps.log(
697
+ isRebuild
698
+ ? 'Rebuilding (this may take a minute — Docker is rebuilding your image)…'
699
+ : 'Deploying (this may take a minute — Docker is building your image)…'
700
+ );
639
701
  await deps.runner.deploy({
640
702
  name: opts.name,
641
703
  // In git mode, there's no -f to re-pass on deploy — siteio uses
@@ -693,7 +755,7 @@ export async function runTeleport(
693
755
  : null;
694
756
 
695
757
  deps.log('');
696
- deps.log('Teleport complete!');
758
+ deps.log(isRebuild ? 'Rebuild complete!' : 'Teleport complete!');
697
759
  if (url) {
698
760
  deps.log(` URL: ${url}`);
699
761
  deps.log(` Health: ${url}/health`);
@@ -703,10 +765,14 @@ export async function runTeleport(
703
765
  ` URL: (siteio did not return a URL — run \`siteio apps info ${opts.name}\` to look it up)`
704
766
  );
705
767
  }
706
- deps.log(` API key: ${serverApiKey}`);
707
- deps.log(' (you will type this into the Authorize page when Claude Code first connects)');
768
+ if (isRebuild) {
769
+ deps.log(' API key: (unchanged existing clients keep their bearer)');
770
+ } else {
771
+ deps.log(` API key: ${serverApiKey}`);
772
+ deps.log(' (you will type this into the Authorize page when Claude Code first connects)');
773
+ }
708
774
  deps.log('');
709
- if (claudeCmd) {
775
+ if (claudeCmd && !isRebuild) {
710
776
  deps.log('To add to Claude Code:');
711
777
  deps.log(` ${claudeCmd}`);
712
778
  deps.log(
@@ -173,9 +173,10 @@ describe('handleRequest — /health adversarial paths', () => {
173
173
  expect(res.status).toBe(404);
174
174
  });
175
175
 
176
- test('GET / (root) → 404', async () => {
176
+ test('GET / (root) → 200 setup page (login form)', async () => {
177
177
  const res = await dispatch(req('/'));
178
- expect(res.status).toBe(404);
178
+ expect(res.status).toBe(200);
179
+ expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
179
180
  });
180
181
  });
181
182
 
@@ -1,6 +1,7 @@
1
1
  import { handleMcpRequest } from './mcp-http';
2
2
  import type { OAuthStore } from './oauth-store';
3
3
  import { requireBearer, routeOAuth } from './oauth';
4
+ import { routeSetup } from './setup-page';
4
5
 
5
6
  /**
6
7
  * Context passed to every fetch handler invocation. Built once at boot in
@@ -35,6 +36,11 @@ export async function handleRequest(
35
36
  return jsonResponse({ ok: true });
36
37
  }
37
38
 
39
+ // Root setup page — operator-facing UI (API-key gated) for picking
40
+ // profiles and generating the MCP URL.
41
+ const setupResponse = await routeSetup(req, ctx);
42
+ if (setupResponse) return setupResponse;
43
+
38
44
  // OAuth metadata + endpoints (Phase 3c onward).
39
45
  const oauthResponse = await routeOAuth(req, ctx);
40
46
  if (oauthResponse) return oauthResponse;
@@ -0,0 +1,325 @@
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { createHmac } from 'crypto';
3
+
4
+ /**
5
+ * Tests for the operator-facing setup page at `/`. Covers:
6
+ * - unauthenticated GET renders the login form
7
+ * - POST credential validation (bad key → 401; good key → 302 + Set-Cookie)
8
+ * - cookie attributes (HttpOnly, SameSite, Path, Max-Age; Secure only on https)
9
+ * - HMAC-cookie authentication: valid cookie → profiles page; stale cookie → login form
10
+ * - only services with ≥1 configured profile appear on the page
11
+ * - embedded JSON payload escapes `<` to avoid `</script>` injection
12
+ *
13
+ * The page calls `listProfiles()` from ../config/config-manager, which reads
14
+ * the real ~/.config/agentio/config.json. Bun caches `os.homedir()` at
15
+ * process start, so HOME overrides don't work here — instead we use
16
+ * `mock.module` to replace `listProfiles` with a controllable stub.
17
+ */
18
+
19
+ const API_KEY = 'srv_test_setup_page_api_key';
20
+ const COOKIE_NAME = 'agentio_setup';
21
+ const COOKIE_MAGIC = 'agentio-setup-v1';
22
+
23
+ function expectedCookieValue(apiKey: string): string {
24
+ return createHmac('sha256', apiKey).update(COOKIE_MAGIC).digest('base64url');
25
+ }
26
+
27
+ interface FakeProfile {
28
+ service: string;
29
+ profiles: { name: string; readOnly?: boolean }[];
30
+ }
31
+
32
+ // Mutable container the mock consults on each call.
33
+ let currentProfiles: FakeProfile[] = [];
34
+
35
+ function setProfiles(fixture: Record<string, string[]>): void {
36
+ currentProfiles = Object.entries(fixture).map(([service, names]) => ({
37
+ service,
38
+ profiles: names.map((n) => ({ name: n })),
39
+ }));
40
+ }
41
+
42
+ type HandleRequest = (typeof import('./http'))['handleRequest'];
43
+ type CreateOAuthStore = (typeof import('./oauth-store'))['createOAuthStore'];
44
+ type ServerContext = import('./http').ServerContext;
45
+
46
+ let handleRequest: HandleRequest;
47
+ let createOAuthStore: CreateOAuthStore;
48
+ let ctx: ServerContext;
49
+
50
+ beforeAll(async () => {
51
+ // Load the real config-manager first so we can preserve every export
52
+ // other modules in the graph depend on (CONFIG_DIR, loadConfig, etc.),
53
+ // then override ONLY listProfiles with a controllable stub. This has
54
+ // to happen BEFORE the module-under-test is imported — once './http'
55
+ // loads, the dependency is bound.
56
+ const realConfig = await import('../config/config-manager');
57
+ mock.module('../config/config-manager', () => ({
58
+ ...realConfig,
59
+ listProfiles: async (service?: string) => {
60
+ if (service) {
61
+ return currentProfiles.filter((s) => s.service === service);
62
+ }
63
+ return currentProfiles;
64
+ },
65
+ }));
66
+
67
+ ({ handleRequest } = await import('./http'));
68
+ ({ createOAuthStore } = await import('./oauth-store'));
69
+ ctx = {
70
+ apiKey: API_KEY,
71
+ oauthStore: createOAuthStore({ save: async () => {} }),
72
+ };
73
+ });
74
+
75
+ beforeEach(() => {
76
+ currentProfiles = [];
77
+ });
78
+
79
+ afterEach(() => {
80
+ currentProfiles = [];
81
+ });
82
+
83
+ function req(
84
+ method: string,
85
+ path: string,
86
+ init: { cookie?: string; xfProto?: string; body?: string; contentType?: string } = {}
87
+ ): Request {
88
+ const headers = new Headers();
89
+ if (init.cookie) headers.set('cookie', init.cookie);
90
+ if (init.xfProto) headers.set('x-forwarded-proto', init.xfProto);
91
+ if (init.contentType) headers.set('content-type', init.contentType);
92
+ return new Request(`http://localhost:9999${path}`, {
93
+ method,
94
+ headers,
95
+ body: init.body,
96
+ });
97
+ }
98
+
99
+ function formBody(fields: Record<string, string>): string {
100
+ return new URLSearchParams(fields).toString();
101
+ }
102
+
103
+ const FORM = 'application/x-www-form-urlencoded';
104
+
105
+ /* ------------------------------------------------------------------ */
106
+ /* unauthenticated GET */
107
+ /* ------------------------------------------------------------------ */
108
+
109
+ describe('GET / — unauthenticated', () => {
110
+ test('renders HTML login form with 200', async () => {
111
+ const res = await handleRequest(req('GET', '/'), ctx);
112
+ expect(res.status).toBe(200);
113
+ expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
114
+ const html = await res.text();
115
+ expect(html).toContain('<title>agentio MCP setup</title>');
116
+ expect(html).toContain('name="api_key"');
117
+ expect(html).toContain('type="password"');
118
+ expect(html).toContain('action="/"');
119
+ });
120
+
121
+ test('does NOT set a cookie on the login form render', async () => {
122
+ const res = await handleRequest(req('GET', '/'), ctx);
123
+ expect(res.headers.get('set-cookie')).toBeNull();
124
+ });
125
+
126
+ test('does NOT leak profile data to an unauthenticated caller', async () => {
127
+ setProfiles({ gmail: ['work-secret', 'personal-secret'] });
128
+ const res = await handleRequest(req('GET', '/'), ctx);
129
+ const html = await res.text();
130
+ expect(html).not.toContain('work-secret');
131
+ expect(html).not.toContain('personal-secret');
132
+ });
133
+ });
134
+
135
+ /* ------------------------------------------------------------------ */
136
+ /* POST login */
137
+ /* ------------------------------------------------------------------ */
138
+
139
+ describe('POST / — login', () => {
140
+ test('missing api_key → 401 with error message, no cookie', async () => {
141
+ const res = await handleRequest(
142
+ req('POST', '/', { contentType: FORM, body: formBody({}) }),
143
+ ctx
144
+ );
145
+ expect(res.status).toBe(401);
146
+ expect(await res.text()).toContain('Invalid API key');
147
+ expect(res.headers.get('set-cookie')).toBeNull();
148
+ });
149
+
150
+ test('wrong api_key → 401 with error message, no cookie', async () => {
151
+ const res = await handleRequest(
152
+ req('POST', '/', {
153
+ contentType: FORM,
154
+ body: formBody({ api_key: 'not-the-right-key' }),
155
+ }),
156
+ ctx
157
+ );
158
+ expect(res.status).toBe(401);
159
+ expect(await res.text()).toContain('Invalid API key');
160
+ expect(res.headers.get('set-cookie')).toBeNull();
161
+ });
162
+
163
+ test('correct api_key → 302 to /, sets cookie with expected attributes', async () => {
164
+ const res = await handleRequest(
165
+ req('POST', '/', {
166
+ contentType: FORM,
167
+ body: formBody({ api_key: API_KEY }),
168
+ }),
169
+ ctx
170
+ );
171
+ expect(res.status).toBe(302);
172
+ expect(res.headers.get('location')).toBe('/');
173
+
174
+ const setCookie = res.headers.get('set-cookie');
175
+ expect(setCookie).not.toBeNull();
176
+ expect(setCookie).toContain(`${COOKIE_NAME}=`);
177
+ expect(setCookie).toContain('HttpOnly');
178
+ expect(setCookie).toContain('SameSite=Strict');
179
+ expect(setCookie).toContain('Path=/');
180
+ expect(setCookie).toMatch(/Max-Age=\d+/);
181
+
182
+ // Cookie value is deterministic HMAC(apiKey, magic).
183
+ expect(setCookie).toContain(expectedCookieValue(API_KEY));
184
+ });
185
+
186
+ test('Secure flag added when x-forwarded-proto=https', async () => {
187
+ const res = await handleRequest(
188
+ req('POST', '/', {
189
+ contentType: FORM,
190
+ xfProto: 'https',
191
+ body: formBody({ api_key: API_KEY }),
192
+ }),
193
+ ctx
194
+ );
195
+ expect(res.headers.get('set-cookie')).toContain('Secure');
196
+ });
197
+
198
+ test('Secure flag NOT added on plain http', async () => {
199
+ const res = await handleRequest(
200
+ req('POST', '/', {
201
+ contentType: FORM,
202
+ body: formBody({ api_key: API_KEY }),
203
+ }),
204
+ ctx
205
+ );
206
+ expect(res.headers.get('set-cookie')).not.toContain('Secure');
207
+ });
208
+ });
209
+
210
+ /* ------------------------------------------------------------------ */
211
+ /* authenticated GET */
212
+ /* ------------------------------------------------------------------ */
213
+
214
+ describe('GET / — authenticated (valid cookie)', () => {
215
+ const validCookie = () => `${COOKIE_NAME}=${expectedCookieValue(API_KEY)}`;
216
+
217
+ test('valid cookie → renders the profiles page', async () => {
218
+ setProfiles({ gmail: ['work'] });
219
+ const res = await handleRequest(
220
+ req('GET', '/', { cookie: validCookie() }),
221
+ ctx
222
+ );
223
+ expect(res.status).toBe(200);
224
+ const html = await res.text();
225
+ expect(html).toContain('MCP URL');
226
+ expect(html).toContain('Claude Code command');
227
+ expect(html).toContain('type="checkbox"');
228
+ });
229
+
230
+ test('only services with ≥1 profile appear (empty services hidden)', async () => {
231
+ setProfiles({
232
+ gmail: ['work', 'personal'],
233
+ slack: ['team'],
234
+ jira: [], // empty — should NOT render a section
235
+ });
236
+ const res = await handleRequest(
237
+ req('GET', '/', { cookie: validCookie() }),
238
+ ctx
239
+ );
240
+ const html = await res.text();
241
+ expect(html).toContain('<h2>gmail</h2>');
242
+ expect(html).toContain('value="gmail:work"');
243
+ expect(html).toContain('value="gmail:personal"');
244
+ expect(html).toContain('<h2>slack</h2>');
245
+ expect(html).toContain('value="slack:team"');
246
+ expect(html).not.toContain('<h2>jira</h2>');
247
+ });
248
+
249
+ test('no configured profiles → empty-state message, no checkboxes', async () => {
250
+ setProfiles({});
251
+ const res = await handleRequest(
252
+ req('GET', '/', { cookie: validCookie() }),
253
+ ctx
254
+ );
255
+ const html = await res.text();
256
+ expect(html).toContain('No profiles configured');
257
+ expect(html).not.toContain('type="checkbox"');
258
+ });
259
+
260
+ test('embedded JSON does not contain `</` that would close the script tag', async () => {
261
+ setProfiles({ gmail: ['work'] });
262
+ const res = await handleRequest(
263
+ req('GET', '/', { cookie: validCookie() }),
264
+ ctx
265
+ );
266
+ const html = await res.text();
267
+ const marker = 'id="page-data"';
268
+ const scriptIdx = html.indexOf(marker);
269
+ expect(scriptIdx).toBeGreaterThan(-1);
270
+ const open = html.indexOf('>', scriptIdx) + 1;
271
+ const close = html.indexOf('</script>', open);
272
+ const dataJson = html.slice(open, close);
273
+ // No `</` inside the JSON — that's what the `\\u003c` escape guarantees.
274
+ expect(dataJson).not.toContain('</');
275
+ // The data block IS the JSON payload with origin set.
276
+ expect(JSON.parse(dataJson)).toEqual({ origin: 'http://localhost:9999' });
277
+ });
278
+
279
+ test('stale cookie (HMAC of a different api key) → renders login form', async () => {
280
+ setProfiles({ gmail: ['work'] });
281
+ const staleCookie = `${COOKIE_NAME}=${expectedCookieValue('some-old-key')}`;
282
+ const res = await handleRequest(
283
+ req('GET', '/', { cookie: staleCookie }),
284
+ ctx
285
+ );
286
+ expect(res.status).toBe(200);
287
+ const html = await res.text();
288
+ expect(html).toContain('name="api_key"');
289
+ expect(html).not.toContain('MCP URL');
290
+ });
291
+
292
+ test('malformed cookie header does not crash', async () => {
293
+ const res = await handleRequest(
294
+ req('GET', '/', { cookie: 'garbage;;===;' }),
295
+ ctx
296
+ );
297
+ expect(res.status).toBe(200);
298
+ expect(await res.text()).toContain('name="api_key"');
299
+ });
300
+
301
+ test('origin reflects x-forwarded-host/proto (behind a proxy)', async () => {
302
+ setProfiles({ gmail: ['work'] });
303
+ const headers = new Headers();
304
+ headers.set('cookie', validCookie());
305
+ headers.set('x-forwarded-proto', 'https');
306
+ headers.set('x-forwarded-host', 'mcp.example.com');
307
+ const res = await handleRequest(
308
+ new Request('http://localhost:9999/', { method: 'GET', headers }),
309
+ ctx
310
+ );
311
+ const html = await res.text();
312
+ expect(html).toContain('{"origin":"https://mcp.example.com"}');
313
+ });
314
+ });
315
+
316
+ /* ------------------------------------------------------------------ */
317
+ /* method dispatch */
318
+ /* ------------------------------------------------------------------ */
319
+
320
+ describe('method dispatch at /', () => {
321
+ test('DELETE / falls through to 404 (not handled by setup page)', async () => {
322
+ const res = await handleRequest(req('DELETE', '/'), ctx);
323
+ expect(res.status).toBe(404);
324
+ });
325
+ });
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Setup page served at `/` — lets an operator (who holds the API key) pick
3
+ * services/profiles and generate the MCP URL + `claude mcp add` snippet.
4
+ *
5
+ * Auth model: a small login form asks for the API key, then we set an
6
+ * HttpOnly cookie whose value is HMAC(apiKey, magic). Every request
7
+ * re-derives that HMAC and compares. If the key rotates, old cookies
8
+ * stop validating — no session store needed.
9
+ */
10
+
11
+ import { createHmac } from 'crypto';
12
+
13
+ import { listProfiles } from '../config/config-manager';
14
+ import type { ServerContext } from './http';
15
+ import { constantTimeEquals, getRequestOrigin } from './oauth';
16
+
17
+ const COOKIE_NAME = 'agentio_setup';
18
+ const COOKIE_MAGIC = 'agentio-setup-v1';
19
+ const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 12; // 12h
20
+
21
+ function escapeHtml(s: string): string {
22
+ return s
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#39;');
28
+ }
29
+
30
+ function signApiKey(apiKey: string): string {
31
+ return createHmac('sha256', apiKey).update(COOKIE_MAGIC).digest('base64url');
32
+ }
33
+
34
+ function parseCookies(header: string | null): Record<string, string> {
35
+ const out: Record<string, string> = {};
36
+ if (!header) return out;
37
+ for (const part of header.split(';')) {
38
+ const [name, ...rest] = part.trim().split('=');
39
+ if (!name) continue;
40
+ try {
41
+ out[name] = decodeURIComponent(rest.join('='));
42
+ } catch {
43
+ out[name] = rest.join('=');
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function isAuthenticated(req: Request, ctx: ServerContext): boolean {
50
+ const cookies = parseCookies(req.headers.get('cookie'));
51
+ const token = cookies[COOKIE_NAME];
52
+ if (!token) return false;
53
+ return constantTimeEquals(token, signApiKey(ctx.apiKey));
54
+ }
55
+
56
+ function isHttps(req: Request): boolean {
57
+ const forwarded = req.headers.get('forwarded');
58
+ if (forwarded && /proto=https/i.test(forwarded)) return true;
59
+ const xfp = req.headers.get('x-forwarded-proto');
60
+ if (xfp && xfp.toLowerCase() === 'https') return true;
61
+ return new URL(req.url).protocol === 'https:';
62
+ }
63
+
64
+ function buildCookieHeader(value: string, secure: boolean): string {
65
+ const parts = [
66
+ `${COOKIE_NAME}=${encodeURIComponent(value)}`,
67
+ 'HttpOnly',
68
+ 'SameSite=Strict',
69
+ 'Path=/',
70
+ `Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
71
+ ];
72
+ if (secure) parts.push('Secure');
73
+ return parts.join('; ');
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ */
77
+ /* HTML */
78
+ /* ------------------------------------------------------------------ */
79
+
80
+ const BASE_CSS = `
81
+ :root { color-scheme: light dark; }
82
+ body {
83
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
84
+ max-width: 640px;
85
+ margin: 6vh auto;
86
+ padding: 0 24px;
87
+ line-height: 1.5;
88
+ }
89
+ h1 { font-size: 1.4rem; margin-bottom: 0.4rem; }
90
+ h2 {
91
+ font-size: 0.85rem;
92
+ margin: 0 0 0.5rem;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.05em;
95
+ color: #666;
96
+ }
97
+ .meta { color: #666; font-size: 0.9rem; margin-bottom: 1.5rem; }
98
+ .meta code { font-size: 0.85rem; }
99
+ form { display: flex; flex-direction: column; gap: 0.75rem; }
100
+ label { font-weight: 600; }
101
+ input[type=password], input[type=text], textarea {
102
+ padding: 0.6rem 0.75rem;
103
+ font-size: 0.95rem;
104
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
105
+ border: 1px solid #888;
106
+ border-radius: 6px;
107
+ width: 100%;
108
+ box-sizing: border-box;
109
+ background: transparent;
110
+ color: inherit;
111
+ }
112
+ textarea { resize: vertical; }
113
+ button {
114
+ padding: 0.6rem 1rem;
115
+ font-size: 1rem;
116
+ font-weight: 600;
117
+ background: #2563eb;
118
+ color: white;
119
+ border: none;
120
+ border-radius: 6px;
121
+ cursor: pointer;
122
+ }
123
+ button:hover { background: #1d4ed8; }
124
+ .error {
125
+ background: #fee;
126
+ color: #900;
127
+ padding: 0.6rem 0.75rem;
128
+ border-radius: 6px;
129
+ border: 1px solid #fcc;
130
+ margin: 0;
131
+ }
132
+ `;
133
+
134
+ const PROFILES_CSS = `
135
+ .services { display: flex; flex-direction: column; gap: 0.8rem; }
136
+ section.service {
137
+ border: 1px solid #ddd;
138
+ border-radius: 6px;
139
+ padding: 0.6rem 0.9rem;
140
+ }
141
+ label.profile {
142
+ display: block;
143
+ font-weight: normal;
144
+ padding: 0.15rem 0;
145
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
146
+ font-size: 0.9rem;
147
+ cursor: pointer;
148
+ }
149
+ label.profile input { margin-right: 0.5rem; }
150
+ .output { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 1.4rem; }
151
+ .output label { font-weight: 600; font-size: 0.9rem; }
152
+ .output .row { display: flex; gap: 0.5rem; }
153
+ .output .row input, .output .row textarea { flex: 1; }
154
+ .output button {
155
+ align-self: flex-start;
156
+ font-size: 0.85rem;
157
+ padding: 0.5rem 0.9rem;
158
+ }
159
+ @media (prefers-color-scheme: dark) {
160
+ section.service { border-color: #444; }
161
+ input[type=password], input[type=text], textarea { border-color: #555; }
162
+ }
163
+ `;
164
+
165
+ function loginFormHtml(errorMessage?: string): string {
166
+ const errorBlock = errorMessage
167
+ ? `<p class="error">${escapeHtml(errorMessage)}</p>`
168
+ : '';
169
+ return `<!doctype html>
170
+ <html lang="en">
171
+ <head>
172
+ <meta charset="utf-8">
173
+ <title>agentio MCP setup</title>
174
+ <style>${BASE_CSS}</style>
175
+ </head>
176
+ <body>
177
+ <h1>agentio MCP setup</h1>
178
+ <p class="meta">Enter your agentio API key to continue.</p>
179
+ ${errorBlock}
180
+ <form method="post" action="/">
181
+ <label for="api_key">agentio API key</label>
182
+ <input id="api_key" name="api_key" type="password" autocomplete="off" autofocus required>
183
+ <button type="submit">Continue</button>
184
+ </form>
185
+ </body>
186
+ </html>`;
187
+ }
188
+
189
+ interface ServiceProfileList {
190
+ service: string;
191
+ profiles: string[];
192
+ }
193
+
194
+ function profilesPageHtml(origin: string, configured: ServiceProfileList[]): string {
195
+ const dataJson = JSON.stringify({ origin }).replace(/</g, '\\u003c');
196
+
197
+ const sectionsHtml = configured
198
+ .map((s) => {
199
+ const items = s.profiles
200
+ .map((p) => {
201
+ const value = `${s.service}:${p}`;
202
+ return ` <label class="profile"><input type="checkbox" name="sp" value="${escapeHtml(
203
+ value
204
+ )}"> ${escapeHtml(p)}</label>`;
205
+ })
206
+ .join('\n');
207
+ return ` <section class="service">
208
+ <h2>${escapeHtml(s.service)}</h2>
209
+ ${items}
210
+ </section>`;
211
+ })
212
+ .join('\n');
213
+
214
+ const emptyMessage =
215
+ configured.length === 0
216
+ ? `<p class="meta">No profiles configured yet. Add some with <code>agentio &lt;service&gt; profile add</code> in the CLI that owns this server.</p>`
217
+ : '';
218
+
219
+ const body =
220
+ configured.length === 0
221
+ ? emptyMessage
222
+ : `<div class="services">
223
+ ${sectionsHtml}
224
+ </div>
225
+ <div class="output">
226
+ <label for="url">MCP URL</label>
227
+ <div class="row"><input id="url" type="text" readonly></div>
228
+ <button id="copy-url" type="button">Copy URL</button>
229
+ </div>
230
+ <div class="output">
231
+ <label for="snippet">Claude Code command</label>
232
+ <div class="row"><textarea id="snippet" readonly rows="2"></textarea></div>
233
+ <button id="copy-snippet" type="button">Copy command</button>
234
+ </div>`;
235
+
236
+ const script =
237
+ configured.length === 0
238
+ ? ''
239
+ : `<script id="page-data" type="application/json">${dataJson}</script>
240
+ <script>
241
+ (function() {
242
+ const data = JSON.parse(document.getElementById('page-data').textContent);
243
+ const urlEl = document.getElementById('url');
244
+ const snipEl = document.getElementById('snippet');
245
+ const boxes = document.querySelectorAll('input[type=checkbox][name=sp]');
246
+
247
+ function recompute() {
248
+ const selected = Array.from(boxes).filter(b => b.checked).map(b => b.value);
249
+ const base = data.origin + '/mcp';
250
+ const url = selected.length
251
+ ? base + '?services=' + encodeURIComponent(selected.join(','))
252
+ : base;
253
+ urlEl.value = url;
254
+ snipEl.value = 'claude mcp add --scope local --transport http agentio "' + url + '"';
255
+ }
256
+
257
+ boxes.forEach(b => b.addEventListener('change', recompute));
258
+ recompute();
259
+
260
+ function wireCopy(btnId, targetId) {
261
+ const btn = document.getElementById(btnId);
262
+ btn.addEventListener('click', async () => {
263
+ const target = document.getElementById(targetId);
264
+ try {
265
+ await navigator.clipboard.writeText(target.value);
266
+ } catch {
267
+ target.select();
268
+ document.execCommand && document.execCommand('copy');
269
+ }
270
+ const old = btn.textContent;
271
+ btn.textContent = 'Copied!';
272
+ setTimeout(() => { btn.textContent = old; }, 1200);
273
+ });
274
+ }
275
+ wireCopy('copy-url', 'url');
276
+ wireCopy('copy-snippet', 'snippet');
277
+ })();
278
+ </script>`;
279
+
280
+ return `<!doctype html>
281
+ <html lang="en">
282
+ <head>
283
+ <meta charset="utf-8">
284
+ <title>agentio MCP setup</title>
285
+ <style>${BASE_CSS}${PROFILES_CSS}</style>
286
+ </head>
287
+ <body>
288
+ <h1>agentio MCP setup</h1>
289
+ <p class="meta">Tick the profiles to expose, then copy the MCP URL or the <code>claude mcp add</code> command.</p>
290
+ ${body}
291
+ ${script}
292
+ </body>
293
+ </html>`;
294
+ }
295
+
296
+ /* ------------------------------------------------------------------ */
297
+ /* handlers */
298
+ /* ------------------------------------------------------------------ */
299
+
300
+ async function handleSetupGet(
301
+ req: Request,
302
+ ctx: ServerContext
303
+ ): Promise<Response> {
304
+ if (!isAuthenticated(req, ctx)) {
305
+ return new Response(loginFormHtml(), {
306
+ status: 200,
307
+ headers: { 'content-type': 'text/html; charset=utf-8' },
308
+ });
309
+ }
310
+
311
+ const origin = getRequestOrigin(req);
312
+ const all = await listProfiles();
313
+ const configured: ServiceProfileList[] = all
314
+ .filter((s) => s.profiles.length > 0)
315
+ .map((s) => ({
316
+ service: s.service,
317
+ profiles: s.profiles.map((p) => p.name),
318
+ }));
319
+
320
+ return new Response(profilesPageHtml(origin, configured), {
321
+ status: 200,
322
+ headers: { 'content-type': 'text/html; charset=utf-8' },
323
+ });
324
+ }
325
+
326
+ async function handleSetupPost(
327
+ req: Request,
328
+ ctx: ServerContext
329
+ ): Promise<Response> {
330
+ let form: URLSearchParams;
331
+ try {
332
+ const body = await req.text();
333
+ form = new URLSearchParams(body);
334
+ } catch {
335
+ return new Response(loginFormHtml('Could not read form body.'), {
336
+ status: 400,
337
+ headers: { 'content-type': 'text/html; charset=utf-8' },
338
+ });
339
+ }
340
+
341
+ const apiKey = form.get('api_key') ?? '';
342
+ if (!apiKey || !constantTimeEquals(apiKey, ctx.apiKey)) {
343
+ return new Response(loginFormHtml('Invalid API key.'), {
344
+ status: 401,
345
+ headers: { 'content-type': 'text/html; charset=utf-8' },
346
+ });
347
+ }
348
+
349
+ const cookie = buildCookieHeader(signApiKey(ctx.apiKey), isHttps(req));
350
+ return new Response(null, {
351
+ status: 302,
352
+ headers: { location: '/', 'set-cookie': cookie },
353
+ });
354
+ }
355
+
356
+ export async function routeSetup(
357
+ req: Request,
358
+ ctx: ServerContext
359
+ ): Promise<Response | null> {
360
+ const url = new URL(req.url);
361
+ if (url.pathname !== '/') return null;
362
+ if (req.method === 'GET') return handleSetupGet(req, ctx);
363
+ if (req.method === 'POST') return handleSetupPost(req, ctx);
364
+ return null;
365
+ }