From dee63efcf4e6469a7cb98a1df9b23fd9879e1af3 Mon Sep 17 00:00:00 2001 From: Guro Date: Thu, 7 Aug 2025 12:53:44 -0700 Subject: [PATCH] feat: add ttyd and cloudflared tunnel integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for exposing Claude's terminal interface via browser using ttyd and cloudflared tunnel. - Add cloudflare_tunnel_token input parameter to both action.yml files - Spawn ttyd process on port 7681 to serve Claude's CLI interface - Start cloudflared tunnel process with provided token to expose via web - Implement proper process cleanup in finally block - Add 3-second startup delay for process initialization When cloudflare_tunnel_token is provided, users can access Claude's interactive terminal through their browser via the cloudflared tunnel. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- action.yml | 5 ++++ base-action/action.yml | 5 ++++ base-action/src/index.ts | 53 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 00441c0..d482cea 100644 --- a/action.yml +++ b/action.yml @@ -114,6 +114,10 @@ inputs: description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected." required: false default: "" + cloudflare_tunnel_token: + description: "Cloudflare tunnel token to expose Claude UI via browser (optional)" + required: false + default: "" outputs: execution_file: @@ -206,6 +210,7 @@ runs: INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands + INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }} # Model configuration ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }} diff --git a/base-action/action.yml b/base-action/action.yml index a9d626a..d9f91ac 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -87,6 +87,10 @@ inputs: description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)" required: false default: "false" + cloudflare_tunnel_token: + description: "Cloudflare tunnel token to expose Claude UI via browser (optional)" + required: false + default: "" outputs: conclusion: @@ -147,6 +151,7 @@ runs: INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }} + INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }} # Provider configuration ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} diff --git a/base-action/src/index.ts b/base-action/src/index.ts index f4d3724..9b5ae18 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -5,6 +5,7 @@ import { preparePrompt } from "./prepare-prompt"; import { runClaude } from "./run-claude"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { validateEnvironmentVariables } from "./validate-env"; +import { spawn } from "child_process"; async function run() { try { @@ -21,7 +22,40 @@ async function run() { promptFile: process.env.INPUT_PROMPT_FILE || "", }); - await runClaude(promptConfig.path, { + // Setup ttyd and cloudflared tunnel if token provided + let ttydProcess: any = null; + let cloudflaredProcess: any = null; + + if (process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN) { + console.log("Setting up ttyd and cloudflared tunnel..."); + + // Start ttyd process in background + ttydProcess = spawn("ttyd", ["-p", "7681", "-i", "0.0.0.0", "claude"], { + stdio: "inherit", + detached: true, + }); + + ttydProcess.on("error", (error: Error) => { + console.warn(`ttyd process error: ${error.message}`); + }); + + // Start cloudflared tunnel + cloudflaredProcess = spawn("cloudflared", ["tunnel", "run", "--token", process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN], { + stdio: "inherit", + detached: true, + }); + + cloudflaredProcess.on("error", (error: Error) => { + console.warn(`cloudflared process error: ${error.message}`); + }); + + // Give processes time to start up + await new Promise(resolve => setTimeout(resolve, 3000)); + console.log("ttyd and cloudflared tunnel started"); + } + + try { + await runClaude(promptConfig.path, { allowedTools: process.env.INPUT_ALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, maxTurns: process.env.INPUT_MAX_TURNS, @@ -32,6 +66,23 @@ async function run() { fallbackModel: process.env.INPUT_FALLBACK_MODEL, model: process.env.ANTHROPIC_MODEL, }); + } finally { + // Clean up processes + if (ttydProcess) { + try { + ttydProcess.kill("SIGTERM"); + } catch (e) { + console.warn("Failed to terminate ttyd process"); + } + } + if (cloudflaredProcess) { + try { + cloudflaredProcess.kill("SIGTERM"); + } catch (e) { + console.warn("Failed to terminate cloudflared process"); + } + } + } } catch (error) { core.setFailed(`Action failed with error: ${error}`); core.setOutput("conclusion", "failure");