@rubytech/taskmaster 1.0.73 → 1.0.75

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.
@@ -6,8 +6,8 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-DiwtIkvl.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-BCh3mx9Z.css">
9
+ <script type="module" crossorigin src="./assets/index-Xczjd9U0.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-B7exVNNa.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -219,3 +219,18 @@ export function isExternalRequest(req, trustedProxies) {
219
219
  });
220
220
  return !isLocalGatewayAddress(clientIp);
221
221
  }
222
+ /**
223
+ * Return the effective trusted proxies list, auto-including 127.0.0.1 when
224
+ * Tailscale Serve/Funnel is active. Tailscale proxies internet traffic from
225
+ * 127.0.0.1 with x-forwarded-for headers — without trusting that address,
226
+ * `isExternalRequest()` would classify internet traffic as local and bypass
227
+ * the funnel restriction.
228
+ */
229
+ export function getEffectiveTrustedProxies(cfg) {
230
+ const configured = cfg.gateway?.trustedProxies ?? [];
231
+ const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
232
+ if (tailscaleMode !== "off" && !configured.includes("127.0.0.1")) {
233
+ return [...configured, "127.0.0.1"];
234
+ }
235
+ return configured;
236
+ }
@@ -10,7 +10,7 @@ import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-chan
10
10
  import { authorizeGatewayConnect } from "../../auth.js";
11
11
  import { loadConfig } from "../../../config/config.js";
12
12
  import { buildDeviceAuthPayload } from "../../device-auth.js";
13
- import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
13
+ import { getEffectiveTrustedProxies, isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp, } from "../../net.js";
14
14
  import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
15
15
  import { ErrorCodes, errorShape, formatValidationErrors, PROTOCOL_VERSION, validateConnectParams, validateRequestFrame, } from "../../protocol/index.js";
16
16
  import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
@@ -68,7 +68,7 @@ function formatGatewayAuthFailureMessage(params) {
68
68
  export function attachGatewayWsMessageHandler(params) {
69
69
  const { socket, upgradeReq, connId, remoteAddr, forwardedFor, realIp, requestHost, requestOrigin, requestUserAgent, canvasHostUrl, connectNonce, resolvedAuth, gatewayMethods, events, extraHandlers, buildRequestContext, send, close, isClosed, clearHandshakeTimer, getClient, setClient, setHandshakeState, setCloseCause, setLastFrameMeta, logGateway, logHealth, logWsControl, } = params;
70
70
  const configSnapshot = loadConfig();
71
- const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
71
+ const trustedProxies = getEffectiveTrustedProxies(configSnapshot);
72
72
  const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies });
73
73
  // If proxy headers are present but the remote address isn't trusted, don't treat
74
74
  // the connection as local. This prevents auth bypass when running behind a reverse
@@ -7,7 +7,7 @@ import { createCloudApiWebhookHandler } from "../web/providers/cloud/webhook-htt
7
7
  import { resolveAgentAvatar } from "../agents/identity-avatar.js";
8
8
  import { handleBrandIconRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest, handlePublicChatHttpRequest, handlePublicWidgetRequest, } from "./control-ui.js";
9
9
  import { isLicensed } from "../license/state.js";
10
- import { isExternalRequest } from "./net.js";
10
+ import { getEffectiveTrustedProxies, isExternalRequest } from "./net.js";
11
11
  import { extractHookToken, getHookChannelError, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody, resolveHookChannel, resolveHookDeliver, } from "./hooks.js";
12
12
  import { applyHookMappings } from "./hooks-mapping.js";
13
13
  import { handleOpenAiHttpRequest } from "./openai-http.js";
@@ -160,7 +160,7 @@ export function createGatewayHttpServer(opts) {
160
160
  return;
161
161
  // Funnel restriction: block non-local requests from accessing non-public paths.
162
162
  // /public/* is already handled above, so any request reaching here is non-public.
163
- const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
163
+ const trustedProxies = getEffectiveTrustedProxies(configSnapshot);
164
164
  if (isExternalRequest(req, trustedProxies)) {
165
165
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
166
166
  if (!pathname.startsWith("/public/")) {
@@ -0,0 +1,258 @@
1
+ /**
2
+ * RPC handlers for Tailscale internet access management.
3
+ * All methods require operator.admin scope (enforced by the catch-all in server-methods.ts).
4
+ */
5
+ import { CONFIG_PATH_TASKMASTER, loadConfig, readConfigFileSnapshot, validateConfigObjectWithPlugins, writeConfigFile, } from "../../config/config.js";
6
+ import { applyMergePatch } from "../../config/merge-patch.js";
7
+ import { findTailscaleBinary, getTailnetHostname, readTailscaleStatusJson, } from "../../infra/tailscale.js";
8
+ import { runExec } from "../../process/exec.js";
9
+ import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
10
+ import { formatDoctorNonInteractiveHint, writeRestartSentinel, } from "../../infra/restart-sentinel.js";
11
+ import { ErrorCodes, errorShape } from "../protocol/index.js";
12
+ export const tailscaleHandlers = {
13
+ /**
14
+ * Return the current Tailscale state on this device.
15
+ */
16
+ "tailscale.status": async ({ respond, context }) => {
17
+ try {
18
+ const binary = await findTailscaleBinary();
19
+ if (!binary) {
20
+ respond(true, {
21
+ installed: false,
22
+ running: false,
23
+ loggedIn: false,
24
+ hostname: null,
25
+ funnelEnabled: false,
26
+ publicUrl: null,
27
+ });
28
+ return;
29
+ }
30
+ // Check if the daemon is running and whether we're logged in
31
+ let running = false;
32
+ let loggedIn = false;
33
+ try {
34
+ const status = await readTailscaleStatusJson();
35
+ running = true;
36
+ // BackendState "Running" means logged in and connected to the tailnet
37
+ loggedIn = status.BackendState === "Running";
38
+ }
39
+ catch {
40
+ // Daemon not running or not logged in
41
+ }
42
+ let hostname = null;
43
+ if (loggedIn) {
44
+ try {
45
+ hostname = await getTailnetHostname(runExec, binary);
46
+ }
47
+ catch {
48
+ // Failed to resolve hostname
49
+ }
50
+ }
51
+ const cfg = loadConfig();
52
+ const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
53
+ // Check actual funnel state — config may say "funnel" but the command
54
+ // may have failed (e.g. tailnet ACL doesn't allow funnel).
55
+ let funnelActive = false;
56
+ if (tailscaleMode === "funnel" && loggedIn) {
57
+ try {
58
+ const { stdout } = await runExec(binary, ["funnel", "status"], {
59
+ timeoutMs: 5_000,
60
+ maxBuffer: 200_000,
61
+ });
62
+ // "No serve config" means funnel isn't actually proxying anything
63
+ funnelActive = !!stdout.trim() && !stdout.includes("No serve config");
64
+ }
65
+ catch {
66
+ // Command failed — funnel not active
67
+ }
68
+ }
69
+ const funnelEnabled = tailscaleMode === "funnel";
70
+ const publicUrl = funnelActive && hostname ? `https://${hostname}` : null;
71
+ respond(true, {
72
+ installed: true,
73
+ running,
74
+ loggedIn,
75
+ hostname,
76
+ funnelEnabled,
77
+ funnelActive,
78
+ publicUrl,
79
+ });
80
+ }
81
+ catch (err) {
82
+ context.logGateway.warn(`tailscale.status failed: ${err instanceof Error ? err.message : String(err)}`);
83
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to check Tailscale status"));
84
+ }
85
+ },
86
+ /**
87
+ * Start the Tailscale login flow. Runs `tailscale up` and captures the
88
+ * auth URL printed to stdout. The UI displays this as a QR code + link.
89
+ * After the user authenticates, the UI polls `tailscale.status` until
90
+ * `loggedIn` becomes true.
91
+ */
92
+ "tailscale.enable": async ({ respond, context }) => {
93
+ try {
94
+ const binary = await findTailscaleBinary();
95
+ if (!binary) {
96
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Tailscale is not installed on this device"));
97
+ return;
98
+ }
99
+ // Run `tailscale up` — on a headless device this prints:
100
+ // "To authenticate, visit:\n\n\thttps://login.tailscale.com/a/..."
101
+ // The command blocks until auth completes, so we run it with a
102
+ // timeout and parse the auth URL from the output. The URL appears
103
+ // within seconds; the timeout just kills the blocking wait.
104
+ const child = await runExec(binary, ["up"], {
105
+ timeoutMs: 60_000,
106
+ maxBuffer: 100_000,
107
+ }).catch((err) => {
108
+ // `tailscale up` exits non-zero while waiting for auth but still
109
+ // prints the URL to stdout/stderr before the timeout kills it.
110
+ const errObj = err;
111
+ return {
112
+ stdout: typeof errObj.stdout === "string" ? errObj.stdout : "",
113
+ stderr: typeof errObj.stderr === "string" ? errObj.stderr : "",
114
+ };
115
+ });
116
+ const combined = `${child.stdout}\n${child.stderr}`;
117
+ const urlMatch = combined.match(/https:\/\/login\.tailscale\.com\/a\/[A-Za-z0-9]+/);
118
+ if (urlMatch) {
119
+ respond(true, { authUrl: urlMatch[0] });
120
+ return;
121
+ }
122
+ // No auth URL found — maybe already logged in
123
+ try {
124
+ const status = await readTailscaleStatusJson();
125
+ if (status.BackendState === "Running") {
126
+ respond(true, { authUrl: null, alreadyLoggedIn: true });
127
+ return;
128
+ }
129
+ }
130
+ catch {
131
+ // ignore
132
+ }
133
+ context.logGateway.warn(`tailscale.enable: no auth URL found in output: ${combined.slice(0, 500)}`);
134
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Could not start Tailscale login — no authentication URL received"));
135
+ }
136
+ catch (err) {
137
+ context.logGateway.warn(`tailscale.enable failed: ${err instanceof Error ? err.message : String(err)}`);
138
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to start Tailscale login"));
139
+ }
140
+ },
141
+ /**
142
+ * Enable Tailscale Funnel by pre-flighting the `tailscale funnel` command,
143
+ * then patching the config if it succeeds. The pre-flight catches the
144
+ * "Funnel is not enabled on your tailnet" case and surfaces the enable URL.
145
+ */
146
+ "tailscale.funnel.enable": async ({ respond, context }) => {
147
+ try {
148
+ const binary = await findTailscaleBinary();
149
+ if (!binary) {
150
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Tailscale is not installed"));
151
+ return;
152
+ }
153
+ // Verify Tailscale is logged in before enabling Funnel
154
+ try {
155
+ const status = await readTailscaleStatusJson();
156
+ if (status.BackendState !== "Running") {
157
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "Tailscale is not logged in — complete login first"));
158
+ return;
159
+ }
160
+ }
161
+ catch {
162
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Cannot reach Tailscale — is it running?"));
163
+ return;
164
+ }
165
+ // Pre-flight: attempt `tailscale funnel` to verify the tailnet allows it.
166
+ // If the tailnet ACL policy doesn't permit funnel, the command prints a
167
+ // helpful message with an enable URL — capture it for the UI.
168
+ const cfg = loadConfig();
169
+ const port = cfg.gateway?.port ?? 18789;
170
+ try {
171
+ await runExec(binary, ["funnel", "--bg", "--yes", `${port}`], {
172
+ timeoutMs: 15_000,
173
+ maxBuffer: 200_000,
174
+ });
175
+ }
176
+ catch (funnelErr) {
177
+ const errObj = funnelErr;
178
+ const combined = `${typeof errObj.stdout === "string" ? errObj.stdout : ""}\n${typeof errObj.stderr === "string" ? errObj.stderr : ""}`;
179
+ // Check for "Funnel is not enabled" — extract the enable URL
180
+ if (combined.includes("Funnel is not enabled") ||
181
+ combined.includes("not enabled on your tailnet")) {
182
+ const enableUrlMatch = combined.match(/https:\/\/login\.tailscale\.com\/f\/funnel\S*/);
183
+ const enableUrl = enableUrlMatch?.[0] ?? "https://login.tailscale.com/admin/machines";
184
+ context.logGateway.warn(`tailscale.funnel.enable: Funnel not enabled on tailnet`);
185
+ respond(false, { enableUrl }, errorShape(ErrorCodes.INVALID_REQUEST, "Funnel is not enabled on your Tailscale account. Open the link in the browser to enable it, then try again."));
186
+ return;
187
+ }
188
+ // Some other error — try sudo fallback isn't relevant here (gateway
189
+ // process may not have sudo). Just report the error.
190
+ context.logGateway.warn(`tailscale.funnel.enable pre-flight failed: ${combined.slice(0, 500)}`);
191
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `Funnel command failed — ${combined.trim().split("\n")[0] || "unknown error"}`));
192
+ return;
193
+ }
194
+ // Funnel is active. Patch config so it persists across restarts.
195
+ await patchTailscaleMode("funnel", respond, context.logGateway);
196
+ }
197
+ catch (err) {
198
+ context.logGateway.warn(`tailscale.funnel.enable failed: ${err instanceof Error ? err.message : String(err)}`);
199
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to enable Funnel"));
200
+ }
201
+ },
202
+ /**
203
+ * Disable Tailscale Funnel by patching config to mode=off.
204
+ * Triggers gateway restart.
205
+ */
206
+ "tailscale.funnel.disable": async ({ respond, context }) => {
207
+ try {
208
+ await patchTailscaleMode("off", respond, context.logGateway);
209
+ }
210
+ catch (err) {
211
+ context.logGateway.warn(`tailscale.funnel.disable failed: ${err instanceof Error ? err.message : String(err)}`);
212
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to disable Funnel"));
213
+ }
214
+ },
215
+ };
216
+ /**
217
+ * Patch `gateway.tailscale.mode` in the config file and schedule a restart.
218
+ */
219
+ async function patchTailscaleMode(mode, respond, log) {
220
+ const snapshot = await readConfigFileSnapshot();
221
+ if (!snapshot.valid) {
222
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "config file is invalid — fix before changing Tailscale settings"));
223
+ return;
224
+ }
225
+ const patch = { gateway: { tailscale: { mode } } };
226
+ const merged = applyMergePatch(snapshot.config, patch);
227
+ const validated = validateConfigObjectWithPlugins(merged);
228
+ if (!validated.ok) {
229
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config after patch", {
230
+ details: { issues: validated.issues },
231
+ }));
232
+ return;
233
+ }
234
+ await writeConfigFile(validated.config);
235
+ const sentinelPayload = {
236
+ kind: "config-apply",
237
+ status: "ok",
238
+ ts: Date.now(),
239
+ message: `Tailscale ${mode === "off" ? "disabled" : mode + " enabled"}`,
240
+ doctorHint: formatDoctorNonInteractiveHint(),
241
+ stats: { mode: "tailscale", root: CONFIG_PATH_TASKMASTER },
242
+ };
243
+ try {
244
+ await writeRestartSentinel(sentinelPayload);
245
+ }
246
+ catch {
247
+ log.warn("tailscale: failed to write restart sentinel");
248
+ }
249
+ const restart = scheduleGatewaySigusr1Restart({
250
+ reason: `tailscale.${mode === "off" ? "disable" : "enable"}`,
251
+ });
252
+ respond(true, {
253
+ ok: true,
254
+ mode,
255
+ restart,
256
+ path: CONFIG_PATH_TASKMASTER,
257
+ });
258
+ }
@@ -33,6 +33,7 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js";
33
33
  import { webHandlers } from "./server-methods/web.js";
34
34
  import { wizardHandlers } from "./server-methods/wizard.js";
35
35
  import { publicChatHandlers } from "./server-methods/public-chat.js";
36
+ import { tailscaleHandlers } from "./server-methods/tailscale.js";
36
37
  import { workspacesHandlers } from "./server-methods/workspaces.js";
37
38
  const ADMIN_SCOPE = "operator.admin";
38
39
  const READ_SCOPE = "operator.read";
@@ -235,6 +236,7 @@ export const coreGatewayHandlers = {
235
236
  ...recordsHandlers,
236
237
  ...workspacesHandlers,
237
238
  ...publicChatHandlers,
239
+ ...tailscaleHandlers,
238
240
  };
239
241
  export async function handleGatewayRequest(opts) {
240
242
  const { req, respond, client, isWebchatConnect, context } = opts;
@@ -35,12 +35,6 @@ export async function resolveGatewayRuntimeConfig(params) {
35
35
  const hooksConfig = resolveHooksConfig(params.cfg);
36
36
  const canvasHostEnabled = process.env.TASKMASTER_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
37
37
  assertGatewayAuthConfigured(resolvedAuth);
38
- if (tailscaleMode === "funnel" && authMode !== "password") {
39
- throw new Error("tailscale funnel requires gateway auth mode=password (set gateway.auth.password or TASKMASTER_GATEWAY_PASSWORD)");
40
- }
41
- if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
42
- throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
43
- }
44
38
  if (!isLoopbackHost(bindHost) && authMode === "none") {
45
39
  throw new Error(`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token or TASKMASTER_GATEWAY_TOKEN, or pass --token)`);
46
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.73",
3
+ "version": "1.0.75",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -149,9 +149,16 @@ if [ "$(id -u)" = "0" ] && [ "$REAL_USER" != "root" ]; then
149
149
  && echo " hostname set to '${TM_HOSTNAME}'" \
150
150
  || echo " hostname set failed (continuing)"
151
151
 
152
- # Ensure /etc/hosts resolves the new hostname (sudo warns otherwise)
152
+ # Ensure /etc/hosts resolves the new hostname (sudo warns otherwise).
153
+ # Raspberry Pi OS uses 127.0.1.1 for the hostname; other distros may use
154
+ # 127.0.0.1. We update an existing 127.0.1.1 line if present (replacing
155
+ # the old hostname like "raspberrypi"), otherwise append a new entry.
153
156
  if ! grep -q "$TM_HOSTNAME" /etc/hosts 2>/dev/null; then
154
- echo "127.0.0.1 $TM_HOSTNAME" >> /etc/hosts
157
+ if grep -q "^127\.0\.1\.1" /etc/hosts 2>/dev/null; then
158
+ sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t$TM_HOSTNAME/" /etc/hosts
159
+ else
160
+ echo "127.0.1.1 $TM_HOSTNAME" >> /etc/hosts
161
+ fi
155
162
  fi
156
163
 
157
164
  # Remove stale avahi service file from previous installs (conflicts with gateway Bonjour)
@@ -161,6 +168,15 @@ if [ "$(id -u)" = "0" ] && [ "$REAL_USER" != "root" ]; then
161
168
  systemctl restart avahi-daemon 2>/dev/null || true
162
169
  echo " mDNS: ${TM_HOSTNAME}.local ready"
163
170
 
171
+ # Tailscale (for optional internet access via Funnel)
172
+ if ! command -v tailscale >/dev/null 2>&1; then
173
+ curl -fsSL https://tailscale.com/install.sh | sh >/dev/null 2>&1 \
174
+ && echo " tailscale installed" \
175
+ || echo " tailscale install failed (continuing)"
176
+ else
177
+ echo " tailscale already installed"
178
+ fi
179
+
164
180
  # Enable user services so systemctl --user works after logout
165
181
  REAL_UID=$(id -u "$REAL_USER")
166
182
  loginctl enable-linger "$REAL_USER" 2>/dev/null || true
@@ -888,6 +888,16 @@ The gateway is the software that runs your assistant. If it's showing red:
888
888
  - Try using the IP address: **http://192.168.x.x:18789** (contact Support for assistance)
889
889
  - Wait 2 minutes after power-on for the device to start
890
890
 
891
+ ### "sudo: unable to resolve host taskmaster" warning?
892
+
893
+ This harmless warning appears when the Pi's hostname isn't listed in `/etc/hosts`. Every `sudo` command still works — it's just a cosmetic message. To fix it, open a terminal on the Pi and run:
894
+
895
+ ```
896
+ sudo sh -c 'echo "127.0.1.1 taskmaster" >> /etc/hosts'
897
+ ```
898
+
899
+ Replace `taskmaster` with your actual hostname if you changed it (e.g., `taskmaster-19000` for a custom port). The warning disappears immediately.
900
+
891
901
  ---
892
902
 
893
903
  ## Multiple Accounts