@rubytech/taskmaster 1.0.74 → 1.0.76
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/README.md +8 -0
- package/dist/agents/taskmaster-tools.js +2 -0
- package/dist/agents/tool-policy.js +2 -1
- package/dist/agents/tools/apikeys-tool.js +52 -0
- package/dist/build-info.json +3 -3
- package/dist/control-ui/assets/{index-BCh3mx9Z.css → index-B7exVNNa.css} +1 -1
- package/dist/control-ui/assets/{index-DiwtIkvl.js → index-hWMGux19.js} +507 -340
- package/dist/control-ui/assets/index-hWMGux19.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/net.js +15 -0
- package/dist/gateway/server/ws-connection/message-handler.js +2 -2
- package/dist/gateway/server-http.js +2 -2
- package/dist/gateway/server-methods/tailscale.js +258 -0
- package/dist/gateway/server-methods/web.js +15 -3
- package/dist/gateway/server-methods.js +2 -0
- package/dist/gateway/server-runtime-config.js +0 -6
- package/package.json +1 -1
- package/scripts/install.sh +9 -0
- package/skills/google-ai/references/browser-setup.md +7 -14
- package/skills/tavily/references/browser-setup.md +7 -14
- package/taskmaster-docs/USER-GUIDE.md +33 -1
- package/dist/control-ui/assets/index-DiwtIkvl.js.map +0 -1
|
@@ -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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-hWMGux19.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-B7exVNNa.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<taskmaster-app></taskmaster-app>
|
package/dist/gateway/net.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
|
@@ -65,7 +65,7 @@ async function ensurePairedAdminBinding(selfPhone, accountId) {
|
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
67
|
// Check if a paired binding already exists for this admin + account
|
|
68
|
-
const
|
|
68
|
+
const existingPairedIdx = bindings.findIndex((b) => {
|
|
69
69
|
if (b.agentId !== adminAgentId)
|
|
70
70
|
return false;
|
|
71
71
|
if (b.match.channel !== "whatsapp")
|
|
@@ -75,8 +75,20 @@ async function ensurePairedAdminBinding(selfPhone, accountId) {
|
|
|
75
75
|
return false;
|
|
76
76
|
return b.match.peer?.kind === "dm" && b.meta?.paired === true;
|
|
77
77
|
});
|
|
78
|
-
if (
|
|
79
|
-
|
|
78
|
+
if (existingPairedIdx !== -1) {
|
|
79
|
+
const existing = bindings[existingPairedIdx];
|
|
80
|
+
if (existing.match.peer?.id === selfPhone) {
|
|
81
|
+
console.log(`[web] ensurePairedAdminBinding: paired binding already exists for ${adminAgentId} on account=${effectiveAccount}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Phone number changed — update the existing binding
|
|
85
|
+
const updatedBindings = [...bindings];
|
|
86
|
+
updatedBindings[existingPairedIdx] = {
|
|
87
|
+
...existing,
|
|
88
|
+
match: { ...existing.match, peer: { kind: "dm", id: selfPhone } },
|
|
89
|
+
};
|
|
90
|
+
await writeConfigFile({ ...cfg, bindings: updatedBindings });
|
|
91
|
+
console.log(`[web] ensurePairedAdminBinding: updated paired binding phone for ${adminAgentId}: ${existing.match.peer?.id} → ${selfPhone} (account=${effectiveAccount})`);
|
|
80
92
|
return;
|
|
81
93
|
}
|
|
82
94
|
// Create the paired admin binding
|
|
@@ -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
package/scripts/install.sh
CHANGED
|
@@ -168,6 +168,15 @@ if [ "$(id -u)" = "0" ] && [ "$REAL_USER" != "root" ]; then
|
|
|
168
168
|
systemctl restart avahi-daemon 2>/dev/null || true
|
|
169
169
|
echo " mDNS: ${TM_HOSTNAME}.local ready"
|
|
170
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
|
+
|
|
171
180
|
# Enable user services so systemctl --user works after logout
|
|
172
181
|
REAL_UID=$(id -u "$REAL_USER")
|
|
173
182
|
loginctl enable-linger "$REAL_USER" 2>/dev/null || true
|
|
@@ -55,24 +55,17 @@ Take a snapshot and check the page content.
|
|
|
55
55
|
|
|
56
56
|
**Send the key as a separate message** — just the key on its own line, nothing else. This lets the user tap and copy it easily from their chat app.
|
|
57
57
|
|
|
58
|
-
## Step 6:
|
|
58
|
+
## Step 6: Store the key
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Use the `api_keys` tool to store the key directly:
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
>
|
|
64
|
-
|
|
65
|
-
> 2. Find the **Google** row (says 'Voice & Video')
|
|
66
|
-
> 3. Paste your key into the field
|
|
67
|
-
> 4. Click **Save**
|
|
68
|
-
>
|
|
69
|
-
> Let me know when it's done and I'll test it!"
|
|
62
|
+
```
|
|
63
|
+
api_keys({ action: "set", provider: "google", apiKey: "<the key>" })
|
|
64
|
+
```
|
|
70
65
|
|
|
71
|
-
|
|
66
|
+
The key is applied immediately — no restart needed. Confirm to the user:
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
> "Google AI is now enabled. You can send me voice notes and videos — I'll understand them both."
|
|
68
|
+
> "Done — I've saved the Google AI key. You can send me voice notes and videos now and I'll understand them both."
|
|
76
69
|
|
|
77
70
|
---
|
|
78
71
|
|
|
@@ -46,24 +46,17 @@ Take a snapshot of the dashboard.
|
|
|
46
46
|
- Copy the key value
|
|
47
47
|
- **Send the key as a separate message** — just the key on its own line, nothing else. This lets the user tap and copy it easily from their chat app.
|
|
48
48
|
|
|
49
|
-
## Step 6:
|
|
49
|
+
## Step 6: Store the key
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Use the `api_keys` tool to store the key directly:
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
>
|
|
55
|
-
|
|
56
|
-
> 2. Find the **Tavily** row (says 'Web Search')
|
|
57
|
-
> 3. Paste your key into the field
|
|
58
|
-
> 4. Click **Save**
|
|
59
|
-
>
|
|
60
|
-
> Let me know when it's done and I'll test it!"
|
|
53
|
+
```
|
|
54
|
+
api_keys({ action: "set", provider: "tavily", apiKey: "<the key>" })
|
|
55
|
+
```
|
|
61
56
|
|
|
62
|
-
|
|
57
|
+
The key is applied immediately — no restart needed. Confirm to the user:
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
> "Web search is now enabled. Try asking me to look something up — 'What's the price of 15mm copper pipe?' or 'Search for the best coffee machines.'"
|
|
59
|
+
> "Done — I've saved the Tavily key. Web search is now enabled. Try asking me to look something up — 'What's the price of 15mm copper pipe?' or 'Search for the best coffee machines.'"
|
|
67
60
|
|
|
68
61
|
---
|
|
69
62
|
|
|
@@ -158,7 +158,7 @@ This check runs automatically — the badge updates on its own without needing t
|
|
|
158
158
|
|
|
159
159
|
Your dashboard is protected by two layers of security:
|
|
160
160
|
|
|
161
|
-
1. **Your network is the main barrier.**
|
|
161
|
+
1. **Your network is the main barrier.** The control panel only runs on your local network (your home or office WiFi). Nobody outside your network can reach it. If you enable Internet Access (see below), only the public chat is exposed — the control panel stays LAN-only.
|
|
162
162
|
2. **PINs prevent casual access within your network.** The PIN stops other people on the same WiFi — family, employees, guests — from opening accounts that aren't theirs.
|
|
163
163
|
|
|
164
164
|
If you enter the wrong PIN 5 times in a row, you'll be locked out for 5 minutes before you can try again.
|
|
@@ -1199,6 +1199,38 @@ No restart is needed — the change takes effect immediately.
|
|
|
1199
1199
|
|
|
1200
1200
|
---
|
|
1201
1201
|
|
|
1202
|
+
## Internet Access
|
|
1203
|
+
|
|
1204
|
+
By default, Taskmaster is only reachable on your local network. If you want people outside your home or office to use the public chat widget — for example, embedding it on your website — you need to enable Internet Access.
|
|
1205
|
+
|
|
1206
|
+
This uses **Tailscale Funnel**, a free service that gives your device a public HTTPS address. Only the public chat is exposed; the control panel stays local-network-only.
|
|
1207
|
+
|
|
1208
|
+
### Enabling Internet Access
|
|
1209
|
+
|
|
1210
|
+
1. Go to the **Setup** page (you must be logged in as admin)
|
|
1211
|
+
2. Find the **Internet Access** row in the status dashboard
|
|
1212
|
+
3. Click **Connect** — a QR code appears
|
|
1213
|
+
4. Scan the QR code with your phone and sign in with your Google or Microsoft account (this creates a free Tailscale account)
|
|
1214
|
+
5. Wait a few seconds — the status changes to "Connected (LAN only)"
|
|
1215
|
+
6. Click **Enable** — if this is your first time, you'll see a message asking you to enable Funnel on your Tailscale account. Click the link, toggle Funnel on, then come back and click Enable again
|
|
1216
|
+
7. Your public URL appears (e.g. `https://taskmaster.tail0e0afb.ts.net`)
|
|
1217
|
+
|
|
1218
|
+
### What gets exposed
|
|
1219
|
+
|
|
1220
|
+
- `/public/*` paths only — the public chat widget and API
|
|
1221
|
+
- The control panel, setup page, and all admin functions stay LAN-only
|
|
1222
|
+
- No one can access your settings, files, or WhatsApp from the internet
|
|
1223
|
+
|
|
1224
|
+
### Disabling
|
|
1225
|
+
|
|
1226
|
+
Click **Disable** on the Internet Access row. The public URL stops working immediately. Your device stays connected to Tailscale but nothing is exposed.
|
|
1227
|
+
|
|
1228
|
+
### Copy your public URL
|
|
1229
|
+
|
|
1230
|
+
When Internet Access is active, click the copy icon next to the URL to copy it to your clipboard. You can share this URL or embed it on your website.
|
|
1231
|
+
|
|
1232
|
+
---
|
|
1233
|
+
|
|
1202
1234
|
## Chat Commands
|
|
1203
1235
|
|
|
1204
1236
|
You can control your assistant's behaviour by typing slash commands in any conversation. These are intercepted by the system — the assistant never sees them.
|