typeclaw 0.10.0 → 0.11.0

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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +37 -4
  3. package/src/agent/restart-handoff/index.ts +91 -0
  4. package/src/agent/restart-handoff/paths.ts +11 -0
  5. package/src/agent/session-origin.ts +30 -10
  6. package/src/agent/subagent-completion-reminder.ts +4 -2
  7. package/src/agent/system-prompt.ts +1 -1
  8. package/src/agent/tools/restart.ts +42 -1
  9. package/src/agent/tools/skip-response.ts +157 -0
  10. package/src/bundled-plugins/memory/README.md +18 -2
  11. package/src/bundled-plugins/memory/index.ts +108 -6
  12. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  13. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  14. package/src/channels/adapters/github/auth-app.ts +53 -9
  15. package/src/channels/adapters/github/auth-pat.ts +4 -1
  16. package/src/channels/adapters/github/auth.ts +10 -0
  17. package/src/channels/adapters/github/event-permissions.ts +83 -0
  18. package/src/channels/adapters/github/inbound.ts +126 -1
  19. package/src/channels/adapters/github/index.ts +60 -66
  20. package/src/channels/adapters/github/outbound.ts +65 -17
  21. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  22. package/src/channels/adapters/github/team-membership.ts +56 -0
  23. package/src/channels/router.ts +213 -32
  24. package/src/channels/schema.ts +8 -7
  25. package/src/channels/types.ts +1 -1
  26. package/src/cli/channel.ts +135 -38
  27. package/src/cli/init.ts +133 -86
  28. package/src/cli/inspect-controller.ts +66 -0
  29. package/src/cli/inspect.ts +24 -32
  30. package/src/cli/run.ts +24 -5
  31. package/src/cli/tui.ts +34 -10
  32. package/src/cli/tunnel.ts +453 -14
  33. package/src/config/config.ts +35 -7
  34. package/src/config/providers.ts +64 -56
  35. package/src/init/env-file.ts +66 -0
  36. package/src/init/hatching.ts +32 -5
  37. package/src/init/index.ts +131 -39
  38. package/src/init/validate-api-key.ts +31 -0
  39. package/src/inspect/index.ts +5 -1
  40. package/src/inspect/loop.ts +12 -1
  41. package/src/inspect/replay.ts +15 -1
  42. package/src/run/codex-fetch-observer.ts +377 -0
  43. package/src/run/index.ts +12 -2
  44. package/src/server/index.ts +59 -1
  45. package/src/shared/protocol.ts +1 -1
  46. package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
  47. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  48. package/src/tui/index.ts +17 -5
  49. package/src/tunnels/index.ts +1 -0
  50. package/src/tunnels/manager.ts +18 -0
  51. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  52. package/src/tunnels/types.ts +17 -1
  53. package/typeclaw.schema.json +25 -7
package/src/cli/tunnel.ts CHANGED
@@ -1,24 +1,35 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
- import { select, text, isCancel, cancel, log } from '@clack/prompts'
4
+ import { select, text, password, isCancel, cancel, log } from '@clack/prompts'
5
5
  import { defineCommand } from 'citty'
6
6
 
7
7
  import { loadConfigSync, validateConfig } from '@/config'
8
8
  import { resolveHostPort, resolveTuiToken } from '@/container'
9
- import { findAgentDir, isInitialized } from '@/init'
9
+ import { appendOrReplaceEnvKey, findAgentDir, hasEnvKey, isInitialized } from '@/init'
10
10
  import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
11
11
  import type { TunnelConfig, TunnelFor, TunnelProvider } from '@/tunnels'
12
12
 
13
13
  import { c, errorLine } from './ui'
14
14
 
15
15
  type AddArgs = {
16
- name: string
16
+ name?: string
17
17
  provider?: string
18
18
  forChannel?: string
19
19
  forManual?: boolean
20
20
  upstreamPort?: string
21
21
  externalUrl?: string
22
+ hostname?: string
23
+ tokenEnv?: string
24
+ }
25
+
26
+ type SetArgs = {
27
+ name?: string
28
+ provider?: string
29
+ upstreamPort?: string
30
+ externalUrl?: string
31
+ hostname?: string
32
+ tokenEnv?: string
22
33
  }
23
34
 
24
35
  type RemoveArgs = { name: string }
@@ -31,12 +42,32 @@ type LiveResult<T> = { ok: true; value: T } | { ok: false; reason: string }
31
42
 
32
43
  export type TextValidator = (value: string) => string | undefined
33
44
 
45
+ export type TunnelSetField = 'provider' | 'externalUrl' | 'hostname' | 'tokenEnv' | 'upstreamPort'
46
+
34
47
  export type TunnelPrompts = {
35
48
  selectProvider: () => Promise<TunnelProvider | symbol>
36
49
  selectOwner: () => Promise<'channel' | 'manual' | symbol>
50
+ // Only `runTunnelSetFlow` uses this; `runTunnelAddFlow` callers can omit it.
51
+ selectSetField?: (
52
+ choices: readonly { value: TunnelSetField; label: string; hint?: string }[],
53
+ ) => Promise<TunnelSetField | symbol>
54
+ // Only `runTunnelSetFlow` uses this when the positional `name` is omitted
55
+ // and more than one tunnel is configured. Optional so existing callers
56
+ // (especially `runTunnelAddFlow` tests) don't have to stub it.
57
+ selectExistingTunnel?: (
58
+ choices: readonly { value: string; label: string; hint?: string }[],
59
+ ) => Promise<string | symbol>
37
60
  text: (message: string, validate?: TextValidator) => Promise<string | symbol>
61
+ // Only fires when the user picks `cloudflare-named` AND the resolved
62
+ // `tokenEnv` is missing from the agent's `.env`. Optional so existing
63
+ // callers/tests don't have to stub it; flows that need it but don't get
64
+ // one fall back to skipping the token write (the user can still set
65
+ // `.env` by hand). Same compat pattern as `selectSetField`.
66
+ password?: (message: string, validate?: TextValidator) => Promise<string | symbol>
38
67
  }
39
68
 
69
+ const DEFAULT_TUNNEL_TOKEN_ENV = 'CLOUDFLARE_TUNNEL_TOKEN'
70
+
40
71
  const DEFAULT_TIMEOUT_MS = 15_000
41
72
 
42
73
  const defaultPrompts: TunnelPrompts = {
@@ -45,6 +76,11 @@ const defaultPrompts: TunnelPrompts = {
45
76
  message: 'Tunnel provider',
46
77
  options: [
47
78
  { value: 'cloudflare-quick', label: 'Cloudflare Quick Tunnel', hint: 'no signup, URL rotates on restart' },
79
+ {
80
+ value: 'cloudflare-named',
81
+ label: 'Cloudflare Named Tunnel',
82
+ hint: 'stable URL, needs Cloudflare account + domain',
83
+ },
48
84
  { value: 'external', label: 'External URL', hint: 'bring your own reverse proxy' },
49
85
  ],
50
86
  }),
@@ -56,28 +92,55 @@ const defaultPrompts: TunnelPrompts = {
56
92
  { value: 'manual', label: 'Manual upstream' },
57
93
  ],
58
94
  }),
95
+ selectSetField: (choices) =>
96
+ select<TunnelSetField>({
97
+ message: 'Which field do you want to change?',
98
+ options: choices.map((choice) => ({
99
+ value: choice.value,
100
+ label: choice.label,
101
+ ...(choice.hint !== undefined ? { hint: choice.hint } : {}),
102
+ })),
103
+ }),
104
+ selectExistingTunnel: (choices) =>
105
+ select<string>({
106
+ message: 'Pick a tunnel to edit',
107
+ options: choices.map((choice) => ({
108
+ value: choice.value,
109
+ label: choice.label,
110
+ ...(choice.hint !== undefined ? { hint: choice.hint } : {}),
111
+ })),
112
+ }),
59
113
  text: (message, validate) =>
60
114
  text({ message, ...(validate !== undefined ? { validate: (v) => validate(v ?? '') } : {}) }),
115
+ password: (message, validate) =>
116
+ password({ message, ...(validate !== undefined ? { validate: (v) => validate(v ?? '') } : {}) }),
61
117
  }
62
118
 
63
119
  const addSub = defineCommand({
64
120
  meta: { name: 'add', description: 'add a public tunnel entry to typeclaw.json' },
65
121
  args: {
66
- name: { type: 'positional', required: true, description: 'tunnel name' },
67
- provider: { type: 'string', description: 'external | cloudflare-quick' },
122
+ name: { type: 'positional', required: false, description: 'tunnel name (omit to prompt interactively)' },
123
+ provider: { type: 'string', description: 'external | cloudflare-quick | cloudflare-named' },
68
124
  'for-channel': { type: 'string', description: 'own this tunnel from a channel adapter' },
69
125
  'for-manual': { type: 'boolean', description: 'create a manually-owned tunnel' },
70
126
  'upstream-port': { type: 'string', description: 'container-local upstream port for manual tunnels' },
71
127
  'external-url': { type: 'string', description: 'https URL for provider=external' },
128
+ hostname: { type: 'string', description: 'https URL for provider=cloudflare-named (dashboard Public Hostname)' },
129
+ 'token-env': {
130
+ type: 'string',
131
+ description: 'env var name holding the cloudflared token (provider=cloudflare-named)',
132
+ },
72
133
  },
73
134
  async run({ args }) {
74
135
  const result = await runTunnelAddFlow(ensureAgentDir(), {
75
- name: String(args.name),
136
+ ...(args.name !== undefined ? { name: String(args.name) } : {}),
76
137
  ...(args.provider !== undefined ? { provider: String(args.provider) } : {}),
77
138
  ...(args['for-channel'] !== undefined ? { forChannel: String(args['for-channel']) } : {}),
78
139
  ...(args['for-manual'] === true ? { forManual: true } : {}),
79
140
  ...(args['upstream-port'] !== undefined ? { upstreamPort: String(args['upstream-port']) } : {}),
80
141
  ...(args['external-url'] !== undefined ? { externalUrl: String(args['external-url']) } : {}),
142
+ ...(args.hostname !== undefined ? { hostname: String(args.hostname) } : {}),
143
+ ...(args['token-env'] !== undefined ? { tokenEnv: String(args['token-env']) } : {}),
81
144
  })
82
145
  if (!result.ok) {
83
146
  console.error(errorLine(result.reason))
@@ -131,6 +194,50 @@ const removeSub = defineCommand({
131
194
  },
132
195
  })
133
196
 
197
+ const setSub = defineCommand({
198
+ meta: {
199
+ name: 'set',
200
+ description: 'edit an existing tunnel entry in typeclaw.json (symmetric with `typeclaw channel set`)',
201
+ },
202
+ args: {
203
+ name: { type: 'positional', required: false, description: 'tunnel name (omit to pick interactively)' },
204
+ provider: { type: 'string', description: 'external | cloudflare-quick | cloudflare-named' },
205
+ 'upstream-port': { type: 'string', description: 'container-local upstream port (manual non-named tunnels)' },
206
+ 'external-url': { type: 'string', description: 'https URL for provider=external' },
207
+ hostname: { type: 'string', description: 'https URL for provider=cloudflare-named (dashboard Public Hostname)' },
208
+ 'token-env': {
209
+ type: 'string',
210
+ description: 'env var name holding the cloudflared token (provider=cloudflare-named)',
211
+ },
212
+ },
213
+ async run({ args }) {
214
+ const result = await runTunnelSetFlow(ensureAgentDir(), {
215
+ ...(args.name !== undefined ? { name: String(args.name) } : {}),
216
+ ...(args.provider !== undefined ? { provider: String(args.provider) } : {}),
217
+ ...(args['upstream-port'] !== undefined ? { upstreamPort: String(args['upstream-port']) } : {}),
218
+ ...(args['external-url'] !== undefined ? { externalUrl: String(args['external-url']) } : {}),
219
+ ...(args.hostname !== undefined ? { hostname: String(args.hostname) } : {}),
220
+ ...(args['token-env'] !== undefined ? { tokenEnv: String(args['token-env']) } : {}),
221
+ })
222
+ if (!result.ok) {
223
+ console.error(errorLine(result.reason))
224
+ process.exit(1)
225
+ }
226
+ log.success(`Updated tunnel "${result.value.name}" in typeclaw.json.`)
227
+ if (result.value.for.kind === 'channel') {
228
+ // The container-side adapter (see src/channels/adapters/github/index.ts)
229
+ // re-runs webhook registration on every start. A restart is required
230
+ // anyway because `tunnels` is restart-required (FIELD_EFFECTS in
231
+ // src/config/config.ts), so on the next start the adapter picks up the
232
+ // new URL and re-points its managed webhooks at it. No CLI-side
233
+ // eager re-install needed.
234
+ log.info(`Run typeclaw restart to apply (the ${result.value.for.name} adapter will re-register its webhooks).`)
235
+ } else {
236
+ log.info('Run typeclaw restart to apply.')
237
+ }
238
+ },
239
+ })
240
+
134
241
  const logsSub = defineCommand({
135
242
  meta: { name: 'logs', description: 'print or follow a tunnel log ring' },
136
243
  args: {
@@ -160,7 +267,7 @@ const logsSub = defineCommand({
160
267
 
161
268
  export const tunnelCommand = defineCommand({
162
269
  meta: { name: 'tunnel', description: 'manage public tunnels for channels and manual upstreams' },
163
- subCommands: { add: addSub, list: listSub, status: statusSub, remove: removeSub, logs: logsSub },
270
+ subCommands: { add: addSub, set: setSub, list: listSub, status: statusSub, remove: removeSub, logs: logsSub },
164
271
  })
165
272
 
166
273
  export async function runTunnelAddFlow(
@@ -178,13 +285,15 @@ export async function runTunnelAddFlow(
178
285
  const validation = validateConfig(cwd)
179
286
  if (!validation.ok) return { ok: false, reason: validation.reason }
180
287
  const config = loadConfigSync(cwd)
181
- if (config.tunnels.some((entry) => entry.name === args.name))
182
- return { ok: false, reason: `tunnel "${args.name}" already exists` }
288
+ const existingNames = new Set(config.tunnels.map((entry) => entry.name))
289
+ const name = args.name ?? (await promptText('Tunnel name', prompts, makeTunnelNameValidator(existingNames)))
290
+ const nameError = validateTunnelName(name, existingNames)
291
+ if (nameError !== undefined) return { ok: false, reason: nameError }
183
292
 
184
293
  const provider = await resolveProvider(args.provider, prompts)
185
294
  const tunnelFor = await resolveFor(args, prompts)
186
295
  let upstreamPort: number | undefined
187
- if (tunnelFor.kind === 'manual') {
296
+ if (tunnelFor.kind === 'manual' && provider !== 'cloudflare-named') {
188
297
  const raw = args.upstreamPort ?? (await promptText('Upstream port', prompts, validateUpstreamPort))
189
298
  const portError = validateUpstreamPort(raw)
190
299
  if (portError !== undefined) return { ok: false, reason: `upstream port: ${portError}` }
@@ -196,17 +305,42 @@ export async function runTunnelAddFlow(
196
305
  const urlError = validateHttpsUrl(externalUrl)
197
306
  if (urlError !== undefined) return { ok: false, reason: `external URL: ${urlError}` }
198
307
  }
308
+ let hostname: string | undefined
309
+ let tokenEnv: string | undefined
310
+ if (provider === 'cloudflare-named') {
311
+ hostname =
312
+ args.hostname ??
313
+ (await promptText(
314
+ 'Public hostname configured in the Cloudflare dashboard (https://...)',
315
+ prompts,
316
+ validateHttpsUrl,
317
+ ))
318
+ const hostnameError = validateHttpsUrl(hostname)
319
+ if (hostnameError !== undefined) return { ok: false, reason: `hostname: ${hostnameError}` }
320
+ tokenEnv = args.tokenEnv ?? DEFAULT_TUNNEL_TOKEN_ENV
321
+ const tokenError = validateTokenEnv(tokenEnv)
322
+ if (tokenError !== undefined) return { ok: false, reason: `token-env: ${tokenError}` }
323
+ // Only prompt for the token VALUE in interactive mode. `--provider` on
324
+ // the CLI signals scripted invocation; bombarding a script with a
325
+ // password prompt it can't satisfy would deadlock CI runs.
326
+ if (args.provider === undefined) {
327
+ const tokenPromptResult = await maybePromptTunnelTokenValue(cwd, tokenEnv, prompts)
328
+ if (!tokenPromptResult.ok) return tokenPromptResult
329
+ }
330
+ }
199
331
 
200
332
  const tunnel: TunnelConfig = {
201
- name: args.name,
333
+ name,
202
334
  provider,
203
335
  for: tunnelFor,
204
336
  ...(externalUrl !== undefined ? { externalUrl } : {}),
205
337
  ...(upstreamPort !== undefined ? { upstreamPort } : {}),
338
+ ...(hostname !== undefined ? { hostname } : {}),
339
+ ...(tokenEnv !== undefined ? { tokenEnv } : {}),
206
340
  }
207
341
  const raw = readRawConfig(cwd)
208
342
  raw.tunnels = [...config.tunnels, tunnel]
209
- if (provider === 'cloudflare-quick') {
343
+ if (provider === 'cloudflare-quick' || provider === 'cloudflare-named') {
210
344
  raw.docker = { ...asRecord(raw.docker), file: { ...asRecord(asRecord(raw.docker).file), cloudflared: true } }
211
345
  }
212
346
  writeRawConfig(cwd, raw)
@@ -224,7 +358,7 @@ export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{
224
358
  if (tunnel.for.kind === 'channel') {
225
359
  return {
226
360
  ok: false,
227
- reason: `tunnel "${args.name}" is owned by channel "${tunnel.for.name}"; run typeclaw channel remove ${tunnel.for.name}`,
361
+ reason: `tunnel "${args.name}" is owned by channel "${tunnel.for.name}". Use \`typeclaw tunnel set ${args.name}\` to change its provider/URL, or hand-edit typeclaw.json to remove both the channel block and the tunnel.`,
228
362
  }
229
363
  }
230
364
  const raw = readRawConfig(cwd)
@@ -234,6 +368,243 @@ export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{
234
368
  return { ok: true, value: { removed: tunnel } }
235
369
  }
236
370
 
371
+ export async function runTunnelSetFlow(
372
+ cwd: string,
373
+ args: SetArgs,
374
+ prompts: TunnelPrompts = defaultPrompts,
375
+ ): Promise<LiveResult<TunnelConfig>> {
376
+ const validation = validateConfig(cwd)
377
+ if (!validation.ok) return { ok: false, reason: validation.reason }
378
+ const config = loadConfigSync(cwd)
379
+ if (config.tunnels.length === 0) {
380
+ return { ok: false, reason: 'no tunnels configured. Run `typeclaw tunnel add` first.' }
381
+ }
382
+ const nameResult =
383
+ args.name !== undefined
384
+ ? { ok: true as const, value: args.name }
385
+ : await resolveExistingTunnelName(config.tunnels, prompts)
386
+ if (!nameResult.ok) return nameResult
387
+ const existing = config.tunnels.find((entry) => entry.name === nameResult.value)
388
+ if (existing === undefined) return { ok: false, reason: `unknown tunnel: ${nameResult.value}` }
389
+
390
+ const flagFields = collectSetFlagFields(args)
391
+ const interactive = flagFields.length === 0
392
+
393
+ let nextProvider = existing.provider
394
+ let nextExternalUrl = existing.externalUrl
395
+ let nextHostname = existing.hostname
396
+ let nextTokenEnv = existing.tokenEnv
397
+ let nextUpstreamPort = existing.upstreamPort
398
+
399
+ if (args.provider !== undefined) {
400
+ const resolved = await resolveProvider(args.provider, prompts)
401
+ nextProvider = resolved
402
+ } else if (interactive) {
403
+ const choices = buildSetFieldChoices(existing)
404
+ if (choices.length === 0) {
405
+ return {
406
+ ok: false,
407
+ reason: `tunnel "${existing.name}" has no editable fields for provider "${existing.provider}"`,
408
+ }
409
+ }
410
+ if (prompts.selectSetField === undefined) {
411
+ return { ok: false, reason: 'interactive set requires selectSetField prompt (pass a flag, e.g. --provider)' }
412
+ }
413
+ const field = await prompts.selectSetField(choices)
414
+ if (isCancel(field)) {
415
+ cancel('Aborted.')
416
+ process.exit(0)
417
+ }
418
+ if (field === 'provider') {
419
+ nextProvider = await resolveProvider(undefined, prompts)
420
+ } else {
421
+ const interactivePatch = await collectInteractiveFieldPatch(field, prompts)
422
+ if (!interactivePatch.ok) return interactivePatch
423
+ nextExternalUrl = interactivePatch.value.externalUrl ?? nextExternalUrl
424
+ nextHostname = interactivePatch.value.hostname ?? nextHostname
425
+ nextTokenEnv = interactivePatch.value.tokenEnv ?? nextTokenEnv
426
+ nextUpstreamPort = interactivePatch.value.upstreamPort ?? nextUpstreamPort
427
+ }
428
+ }
429
+
430
+ if (args.externalUrl !== undefined) {
431
+ const err = validateHttpsUrl(args.externalUrl)
432
+ if (err !== undefined) return { ok: false, reason: `external URL: ${err}` }
433
+ nextExternalUrl = args.externalUrl
434
+ }
435
+ if (args.hostname !== undefined) {
436
+ const err = validateHttpsUrl(args.hostname)
437
+ if (err !== undefined) return { ok: false, reason: `hostname: ${err}` }
438
+ nextHostname = args.hostname
439
+ }
440
+ if (args.tokenEnv !== undefined) {
441
+ const err = validateTokenEnv(args.tokenEnv)
442
+ if (err !== undefined) return { ok: false, reason: `token-env: ${err}` }
443
+ nextTokenEnv = args.tokenEnv
444
+ }
445
+ if (args.upstreamPort !== undefined) {
446
+ const err = validateUpstreamPort(args.upstreamPort)
447
+ if (err !== undefined) return { ok: false, reason: `upstream port: ${err}` }
448
+ nextUpstreamPort = Number(args.upstreamPort)
449
+ }
450
+
451
+ // On a provider switch, drop fields the new provider forbids and require
452
+ // fields the new provider needs. This mirrors the per-provider refinements
453
+ // in tunnelEntrySchema (src/config/config.ts) so the schema-validation
454
+ // round-trip after write doesn't fail on stale fields from the old shape.
455
+ if (nextProvider !== existing.provider) {
456
+ if (nextProvider !== 'external') nextExternalUrl = undefined
457
+ if (nextProvider !== 'cloudflare-named') {
458
+ nextHostname = undefined
459
+ nextTokenEnv = undefined
460
+ }
461
+ if (nextProvider === 'cloudflare-named') nextUpstreamPort = undefined
462
+ if (nextProvider === 'external' && nextExternalUrl === undefined) {
463
+ const raw =
464
+ args.externalUrl ??
465
+ (interactive ? await promptText('External HTTPS URL', prompts, validateHttpsUrl) : undefined)
466
+ if (raw === undefined) return { ok: false, reason: "provider 'external' requires --external-url" }
467
+ const err = validateHttpsUrl(raw)
468
+ if (err !== undefined) return { ok: false, reason: `external URL: ${err}` }
469
+ nextExternalUrl = raw
470
+ }
471
+ if (nextProvider === 'cloudflare-named') {
472
+ if (nextHostname === undefined) {
473
+ const raw =
474
+ args.hostname ??
475
+ (interactive
476
+ ? await promptText(
477
+ 'Public hostname configured in the Cloudflare dashboard (https://...)',
478
+ prompts,
479
+ validateHttpsUrl,
480
+ )
481
+ : undefined)
482
+ if (raw === undefined) return { ok: false, reason: "provider 'cloudflare-named' requires --hostname" }
483
+ const err = validateHttpsUrl(raw)
484
+ if (err !== undefined) return { ok: false, reason: `hostname: ${err}` }
485
+ nextHostname = raw
486
+ }
487
+ if (nextTokenEnv === undefined) {
488
+ const raw = args.tokenEnv ?? DEFAULT_TUNNEL_TOKEN_ENV
489
+ const err = validateTokenEnv(raw)
490
+ if (err !== undefined) return { ok: false, reason: `token-env: ${err}` }
491
+ nextTokenEnv = raw
492
+ }
493
+ }
494
+ if (existing.for.kind === 'manual' && nextProvider !== 'cloudflare-named' && nextUpstreamPort === undefined) {
495
+ const raw = interactive ? await promptText('Upstream port', prompts, validateUpstreamPort) : undefined
496
+ if (raw === undefined)
497
+ return { ok: false, reason: 'manual tunnels require --upstream-port (except cloudflare-named)' }
498
+ const err = validateUpstreamPort(raw)
499
+ if (err !== undefined) return { ok: false, reason: `upstream port: ${err}` }
500
+ nextUpstreamPort = Number(raw)
501
+ }
502
+ }
503
+
504
+ const next: TunnelConfig = {
505
+ name: existing.name,
506
+ provider: nextProvider,
507
+ for: existing.for,
508
+ ...(nextExternalUrl !== undefined ? { externalUrl: nextExternalUrl } : {}),
509
+ ...(nextUpstreamPort !== undefined ? { upstreamPort: nextUpstreamPort } : {}),
510
+ ...(nextHostname !== undefined ? { hostname: nextHostname } : {}),
511
+ ...(nextTokenEnv !== undefined ? { tokenEnv: nextTokenEnv } : {}),
512
+ }
513
+
514
+ if (interactive && next.provider === 'cloudflare-named' && next.tokenEnv !== undefined) {
515
+ const tokenPromptResult = await maybePromptTunnelTokenValue(cwd, next.tokenEnv, prompts)
516
+ if (!tokenPromptResult.ok) return tokenPromptResult
517
+ }
518
+
519
+ const raw = readRawConfig(cwd)
520
+ raw.tunnels = config.tunnels.map((entry) => (entry.name === existing.name ? next : entry))
521
+ if (nextProvider === 'cloudflare-quick' || nextProvider === 'cloudflare-named') {
522
+ raw.docker = { ...asRecord(raw.docker), file: { ...asRecord(asRecord(raw.docker).file), cloudflared: true } }
523
+ }
524
+ writeRawConfig(cwd, raw)
525
+ // The strict gate above already validated the on-disk shape; calling
526
+ // validateConfig again here catches any post-write schema violation (e.g.
527
+ // a provider/field combination the explicit checks above missed) and
528
+ // surfaces it as a clean LiveResult instead of a thrown error on the next
529
+ // `loadConfigSync`. We roll back the file on failure so the user's
530
+ // typeclaw.json doesn't end up in an invalid state.
531
+ const postWrite = validateConfig(cwd)
532
+ if (!postWrite.ok) {
533
+ raw.tunnels = config.tunnels
534
+ writeRawConfig(cwd, raw)
535
+ return { ok: false, reason: postWrite.reason }
536
+ }
537
+ loadConfigSync(cwd)
538
+ return { ok: true, value: next }
539
+ }
540
+
541
+ function collectSetFlagFields(args: SetArgs): TunnelSetField[] {
542
+ const out: TunnelSetField[] = []
543
+ if (args.provider !== undefined) out.push('provider')
544
+ if (args.externalUrl !== undefined) out.push('externalUrl')
545
+ if (args.hostname !== undefined) out.push('hostname')
546
+ if (args.tokenEnv !== undefined) out.push('tokenEnv')
547
+ if (args.upstreamPort !== undefined) out.push('upstreamPort')
548
+ return out
549
+ }
550
+
551
+ function buildSetFieldChoices(existing: TunnelConfig): { value: TunnelSetField; label: string; hint?: string }[] {
552
+ const choices: { value: TunnelSetField; label: string; hint?: string }[] = [
553
+ { value: 'provider', label: 'Provider', hint: `currently ${existing.provider}` },
554
+ ]
555
+ if (existing.provider === 'external') {
556
+ choices.push({ value: 'externalUrl', label: 'External URL', hint: existing.externalUrl ?? '-' })
557
+ }
558
+ if (existing.provider === 'cloudflare-named') {
559
+ choices.push({ value: 'hostname', label: 'Hostname', hint: existing.hostname ?? '-' })
560
+ choices.push({ value: 'tokenEnv', label: 'Token env var name', hint: existing.tokenEnv ?? '-' })
561
+ }
562
+ if (existing.for.kind === 'manual' && existing.provider !== 'cloudflare-named') {
563
+ choices.push({
564
+ value: 'upstreamPort',
565
+ label: 'Upstream port',
566
+ hint: existing.upstreamPort !== undefined ? String(existing.upstreamPort) : '-',
567
+ })
568
+ }
569
+ return choices
570
+ }
571
+
572
+ async function collectInteractiveFieldPatch(
573
+ field: Exclude<TunnelSetField, 'provider'>,
574
+ prompts: TunnelPrompts,
575
+ ): Promise<LiveResult<{ externalUrl?: string; hostname?: string; tokenEnv?: string; upstreamPort?: number }>> {
576
+ switch (field) {
577
+ case 'externalUrl': {
578
+ const value = await promptText('External HTTPS URL', prompts, validateHttpsUrl)
579
+ const err = validateHttpsUrl(value)
580
+ if (err !== undefined) return { ok: false, reason: `external URL: ${err}` }
581
+ return { ok: true, value: { externalUrl: value } }
582
+ }
583
+ case 'hostname': {
584
+ const value = await promptText(
585
+ 'Public hostname configured in the Cloudflare dashboard (https://...)',
586
+ prompts,
587
+ validateHttpsUrl,
588
+ )
589
+ const err = validateHttpsUrl(value)
590
+ if (err !== undefined) return { ok: false, reason: `hostname: ${err}` }
591
+ return { ok: true, value: { hostname: value } }
592
+ }
593
+ case 'tokenEnv': {
594
+ const value = await promptText('Env var name holding the tunnel token', prompts, validateTokenEnv)
595
+ const err = validateTokenEnv(value)
596
+ if (err !== undefined) return { ok: false, reason: `token-env: ${err}` }
597
+ return { ok: true, value: { tokenEnv: value } }
598
+ }
599
+ case 'upstreamPort': {
600
+ const value = await promptText('Upstream port', prompts, validateUpstreamPort)
601
+ const err = validateUpstreamPort(value)
602
+ if (err !== undefined) return { ok: false, reason: `upstream port: ${err}` }
603
+ return { ok: true, value: { upstreamPort: Number(value) } }
604
+ }
605
+ }
606
+ }
607
+
237
608
  export async function fetchTunnelList(opts: {
238
609
  cwd: string
239
610
  url?: string
@@ -378,8 +749,50 @@ function parseLiveArgs(args: LiveArgs): { url?: string; timeoutMs: number } {
378
749
  return { ...(args.url !== undefined ? { url: args.url } : {}), timeoutMs }
379
750
  }
380
751
 
752
+ async function resolveExistingTunnelName(
753
+ tunnels: readonly TunnelConfig[],
754
+ prompts: TunnelPrompts,
755
+ ): Promise<LiveResult<string>> {
756
+ if (tunnels.length === 1) return { ok: true, value: tunnels[0]!.name }
757
+ if (prompts.selectExistingTunnel === undefined) {
758
+ return {
759
+ ok: false,
760
+ reason: 'interactive set requires selectExistingTunnel prompt (pass the tunnel name positionally)',
761
+ }
762
+ }
763
+ const choices = tunnels.map((entry) => ({
764
+ value: entry.name,
765
+ label: entry.name,
766
+ hint: `${entry.provider} · ${entry.for.kind === 'channel' ? `channel:${entry.for.name}` : 'manual'}`,
767
+ }))
768
+ const choice = await prompts.selectExistingTunnel(choices)
769
+ if (isCancel(choice)) {
770
+ cancel('Aborted.')
771
+ process.exit(0)
772
+ }
773
+ return { ok: true, value: choice }
774
+ }
775
+
776
+ async function maybePromptTunnelTokenValue(
777
+ cwd: string,
778
+ tokenEnv: string,
779
+ prompts: TunnelPrompts,
780
+ ): Promise<LiveResult<void>> {
781
+ if (hasEnvKey(cwd, tokenEnv)) return { ok: true, value: undefined }
782
+ if (prompts.password === undefined) return { ok: true, value: undefined }
783
+ const value = await prompts.password(`Cloudflare tunnel token (will be written to .env as ${tokenEnv})`, (v) =>
784
+ v.length > 0 ? undefined : 'Token is required',
785
+ )
786
+ if (isCancel(value)) {
787
+ cancel('Aborted.')
788
+ process.exit(0)
789
+ }
790
+ appendOrReplaceEnvKey(cwd, tokenEnv, value)
791
+ return { ok: true, value: undefined }
792
+ }
793
+
381
794
  async function resolveProvider(input: string | undefined, prompts: TunnelPrompts): Promise<TunnelProvider> {
382
- if (input === 'external' || input === 'cloudflare-quick') return input
795
+ if (input === 'external' || input === 'cloudflare-quick' || input === 'cloudflare-named') return input
383
796
  if (input !== undefined) throw new Error(`unknown tunnel provider: ${input}`)
384
797
  const choice = await prompts.selectProvider()
385
798
  if (isCancel(choice)) {
@@ -419,6 +832,24 @@ function validateNonEmpty(requiredMessage: string): TextValidator {
419
832
  return (value) => (value.trim().length > 0 ? undefined : requiredMessage)
420
833
  }
421
834
 
835
+ // Mirrors the regex on `tunnelEntrySchema.name` in src/config/config.ts so
836
+ // the interactive prompt rejects shapes the post-write schema validation
837
+ // would reject anyway, but with a clear inline error instead of a Zod dump.
838
+ const TUNNEL_NAME_REGEX = /^[a-z0-9][a-z0-9-_]*$/
839
+
840
+ function validateTunnelName(value: string, existing: ReadonlySet<string>): string | undefined {
841
+ if (value.trim().length === 0) return 'Tunnel name is required'
842
+ if (!TUNNEL_NAME_REGEX.test(value)) {
843
+ return 'Tunnel name must match /^[a-z0-9][a-z0-9-_]*$/ (lowercase, digits, dashes, underscores)'
844
+ }
845
+ if (existing.has(value)) return `tunnel "${value}" already exists`
846
+ return undefined
847
+ }
848
+
849
+ function makeTunnelNameValidator(existing: ReadonlySet<string>): TextValidator {
850
+ return (value) => validateTunnelName(value, existing)
851
+ }
852
+
422
853
  function validateUpstreamPort(value: string): string | undefined {
423
854
  if (value.trim().length === 0) return 'Upstream port is required'
424
855
  const port = Number(value)
@@ -437,6 +868,14 @@ function validateHttpsUrl(value: string): string | undefined {
437
868
  }
438
869
  }
439
870
 
871
+ function validateTokenEnv(value: string): string | undefined {
872
+ if (value.trim().length === 0) return 'Env var name is required'
873
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(value)) {
874
+ return 'Must be an env var name like CLOUDFLARE_TUNNEL_TOKEN (uppercase, digits, underscore)'
875
+ }
876
+ return undefined
877
+ }
878
+
440
879
  function ensureAgentDir(): string {
441
880
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
442
881
  if (!isInitialized(cwd)) {
@@ -250,16 +250,24 @@ export type NetworkConfig = z.infer<typeof networkSchema>
250
250
 
251
251
  // Reverse-proxy tunnels expose a container-private port to the public internet
252
252
  // via a managed subprocess (cloudflared) or a user-supplied external URL.
253
- // See AGENTS.md `## Tunnels`. PR 2 ships `cloudflare-quick`; `cloudflare-named`
254
- // remains deferred to PR 3. Keeping the enum scoped to what's implemented means
255
- // validateConfig() rejects unsupported providers at `typeclaw start` time,
256
- // before the container is torn down and rebuilt. `restart-required` because
257
- // the tunnel manager reads this list once at boot.
253
+ // See AGENTS.md `## Tunnels`. Keeping the enum scoped to what's implemented
254
+ // means validateConfig() rejects unsupported providers at `typeclaw start`
255
+ // time, before the container is torn down and rebuilt. `restart-required`
256
+ // because the tunnel manager reads this list once at boot.
258
257
  const tunnelForSchema = z.discriminatedUnion('kind', [
259
258
  z.object({ kind: z.literal('channel'), name: z.string().trim().min(1) }),
260
259
  z.object({ kind: z.literal('manual') }),
261
260
  ])
262
261
 
262
+ // `tokenEnv` is the NAME of an env var, not the token itself. Restrict to the
263
+ // shell-portable identifier shape (uppercase + digits + underscore, leading
264
+ // non-digit) so a value typed here can't break `--env-file` parsing or shell
265
+ // expansion inside the container. Matches the convention every other env var
266
+ // in the codebase already follows (TYPECLAW_*, OPENAI_*, etc.).
267
+ const tokenEnvNameSchema = z.string().regex(/^[A-Z_][A-Z0-9_]*$/, {
268
+ message: 'tokenEnv must be an env var name like CLOUDFLARE_TUNNEL_TOKEN (uppercase, digits, underscore)',
269
+ })
270
+
263
271
  const tunnelEntrySchema = z
264
272
  .object({
265
273
  name: z
@@ -268,7 +276,7 @@ const tunnelEntrySchema = z
268
276
  .regex(/^[a-z0-9][a-z0-9-_]*$/, {
269
277
  message: 'tunnel name must match /^[a-z0-9][a-z0-9-_]*$/ (lowercase, digits, dashes, underscores)',
270
278
  }),
271
- provider: z.enum(['external', 'cloudflare-quick']),
279
+ provider: z.enum(['external', 'cloudflare-quick', 'cloudflare-named']),
272
280
  for: tunnelForSchema,
273
281
  externalUrl: z
274
282
  .string()
@@ -276,11 +284,31 @@ const tunnelEntrySchema = z
276
284
  .refine((u) => u.startsWith('https://'), { message: 'externalUrl must use https://' })
277
285
  .optional(),
278
286
  upstreamPort: z.number().int().min(1).max(65535).optional(),
287
+ hostname: z
288
+ .string()
289
+ .url()
290
+ .refine((u) => u.startsWith('https://'), { message: 'hostname must use https://' })
291
+ .optional(),
292
+ tokenEnv: tokenEnvNameSchema.optional(),
279
293
  })
280
294
  .refine((v) => v.provider !== 'external' || (v.externalUrl !== undefined && v.externalUrl.trim() !== ''), {
281
295
  message: "tunnels[].externalUrl is required when provider is 'external'",
282
296
  })
283
- .refine((v) => v.for.kind !== 'manual' || v.upstreamPort !== undefined, {
297
+ .refine((v) => v.provider !== 'cloudflare-named' || (v.hostname !== undefined && v.hostname.trim() !== ''), {
298
+ message: "tunnels[].hostname is required when provider is 'cloudflare-named'",
299
+ })
300
+ .refine((v) => v.provider !== 'cloudflare-named' || (v.tokenEnv !== undefined && v.tokenEnv.trim() !== ''), {
301
+ message: "tunnels[].tokenEnv is required when provider is 'cloudflare-named'",
302
+ })
303
+ // cloudflared learns the upstream from the Cloudflare dashboard's Public
304
+ // Hostname mapping, not from typeclaw. An `upstreamPort` here would be
305
+ // silently ignored; reject at parse time so the contradiction surfaces in
306
+ // the config file rather than as a debugging surprise.
307
+ .refine((v) => v.provider !== 'cloudflare-named' || v.upstreamPort === undefined, {
308
+ message:
309
+ "tunnels[].upstreamPort must not be set when provider is 'cloudflare-named' (cloudflared reads the upstream from the Cloudflare dashboard)",
310
+ })
311
+ .refine((v) => v.for.kind !== 'manual' || v.provider === 'cloudflare-named' || v.upstreamPort !== undefined, {
284
312
  message: "tunnels[].upstreamPort is required when for.kind is 'manual'",
285
313
  })
286
314