@rubytech/taskmaster 1.0.80 → 1.0.82
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/dist/agents/workspace.js +17 -1
- package/dist/build-info.json +3 -3
- package/dist/gateway/server-methods/files.js +12 -2
- package/dist/gateway/server-methods/web.js +32 -1
- package/dist/routing/resolve-route.js +25 -2
- package/dist/web/auth-store.js +11 -2
- package/dist/web/login-qr.js +8 -8
- package/package.json +1 -1
- package/scripts/install.sh +77 -71
package/dist/agents/workspace.js
CHANGED
|
@@ -132,7 +132,12 @@ export async function ensureAgentWorkspace(params) {
|
|
|
132
132
|
.access(bootstrapDonePath)
|
|
133
133
|
.then(() => true)
|
|
134
134
|
.catch(() => false);
|
|
135
|
-
|
|
135
|
+
// Only write BOOTSTRAP.md for brand-new workspaces. If other workspace
|
|
136
|
+
// files already exist (IDENTITY.md, SOUL.md, etc.) the workspace has been
|
|
137
|
+
// bootstrapped — don't re-create BOOTSTRAP.md even if .bootstrap-done is
|
|
138
|
+
// missing. This prevents the onboarding flow from re-activating after a
|
|
139
|
+
// restart, session reset, or AI failure to write the sentinel.
|
|
140
|
+
if (!bootstrapDone && isBrandNewWorkspace) {
|
|
136
141
|
await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
|
|
137
142
|
}
|
|
138
143
|
await ensureGitRepo(dir, isBrandNewWorkspace);
|
|
@@ -179,8 +184,19 @@ export async function loadWorkspaceBootstrapFiles(dir) {
|
|
|
179
184
|
filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME),
|
|
180
185
|
},
|
|
181
186
|
];
|
|
187
|
+
// If .bootstrap-done exists, treat BOOTSTRAP.md as absent so the onboarding
|
|
188
|
+
// flow is never injected into agent context on a bootstrapped workspace.
|
|
189
|
+
const bootstrapDonePath = path.join(resolvedDir, BOOTSTRAP_DONE_SENTINEL);
|
|
190
|
+
const bootstrapDone = await fs
|
|
191
|
+
.access(bootstrapDonePath)
|
|
192
|
+
.then(() => true)
|
|
193
|
+
.catch(() => false);
|
|
182
194
|
const result = [];
|
|
183
195
|
for (const entry of entries) {
|
|
196
|
+
if (bootstrapDone && entry.name === DEFAULT_BOOTSTRAP_FILENAME) {
|
|
197
|
+
result.push({ name: entry.name, path: entry.filePath, missing: true });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
184
200
|
try {
|
|
185
201
|
const content = await fs.readFile(entry.filePath, "utf-8");
|
|
186
202
|
result.push({
|
package/dist/build-info.json
CHANGED
|
@@ -6,9 +6,19 @@ import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
|
6
6
|
const MAX_PREVIEW_BYTES = 256 * 1024; // 256 KB for preview
|
|
7
7
|
const MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024; // 5 MB for download
|
|
8
8
|
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; // 5 MB for upload
|
|
9
|
+
/**
|
|
10
|
+
* Multi-agent workspaces set each agent's workspace to a subdirectory
|
|
11
|
+
* (e.g. ~/taskmaster/agents/admin). The files page should show the
|
|
12
|
+
* workspace root (~/taskmaster), not the agent subdir.
|
|
13
|
+
*/
|
|
14
|
+
function stripAgentSubdir(agentWorkspaceDir) {
|
|
15
|
+
const normalised = agentWorkspaceDir.replace(/\/+$/, "");
|
|
16
|
+
const match = normalised.match(/^(.+)\/agents\/[^/]+$/);
|
|
17
|
+
return match ? match[1] : normalised;
|
|
18
|
+
}
|
|
9
19
|
function resolveWorkspaceRoot() {
|
|
10
20
|
const cfg = loadConfig();
|
|
11
|
-
return resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
|
21
|
+
return stripAgentSubdir(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
|
|
12
22
|
}
|
|
13
23
|
/**
|
|
14
24
|
* Resolve workspace root from request params.
|
|
@@ -20,7 +30,7 @@ function resolveWorkspaceForRequest(params) {
|
|
|
20
30
|
if (!agentId)
|
|
21
31
|
return resolveWorkspaceRoot();
|
|
22
32
|
const cfg = loadConfig();
|
|
23
|
-
return resolveAgentWorkspaceDir(cfg, agentId);
|
|
33
|
+
return stripAgentSubdir(resolveAgentWorkspaceDir(cfg, agentId));
|
|
24
34
|
}
|
|
25
35
|
/**
|
|
26
36
|
* Validate and resolve a relative path within the workspace.
|
|
@@ -4,6 +4,15 @@ import { ErrorCodes, errorShape, formatValidationErrors, validateWebLoginStartPa
|
|
|
4
4
|
import { formatForLog } from "../ws-log.js";
|
|
5
5
|
const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]);
|
|
6
6
|
const resolveWebLoginProvider = () => listChannelPlugins().find((plugin) => (plugin.gatewayMethods ?? []).some((method) => WEB_LOGIN_METHODS.has(method))) ?? null;
|
|
7
|
+
/**
|
|
8
|
+
* Given an admin agent ID, find the matching public agent.
|
|
9
|
+
* "admin" → "public", "foo-admin" → "foo-public".
|
|
10
|
+
*/
|
|
11
|
+
function resolvePublicCounterpart(adminAgentId, agents) {
|
|
12
|
+
const lower = adminAgentId.toLowerCase();
|
|
13
|
+
const target = lower === "admin" ? "public" : adminAgentId.replace(/-admin$/i, "-public");
|
|
14
|
+
return agents.find((a) => (a.id ?? "").toLowerCase() === target.toLowerCase())?.id ?? undefined;
|
|
15
|
+
}
|
|
7
16
|
/**
|
|
8
17
|
* After a successful WhatsApp QR pairing, auto-create a paired admin binding
|
|
9
18
|
* for the self phone number. This is the single code path for all businesses:
|
|
@@ -101,9 +110,31 @@ async function ensurePairedAdminBinding(selfPhone, accountId) {
|
|
|
101
110
|
},
|
|
102
111
|
meta: { paired: true },
|
|
103
112
|
};
|
|
113
|
+
const newBindings = [newBinding];
|
|
114
|
+
// Ensure a channel-level catch-all for the public agent so unbound DMs
|
|
115
|
+
// route to public, not admin. Without this, the routing default fallback
|
|
116
|
+
// sends every unknown phone number to admin.
|
|
117
|
+
const publicAgentId = resolvePublicCounterpart(adminAgentId, agents);
|
|
118
|
+
if (publicAgentId) {
|
|
119
|
+
const hasPublicCatchAll = bindings.some((b) => b.agentId === publicAgentId &&
|
|
120
|
+
b.match.channel === "whatsapp" &&
|
|
121
|
+
!b.match.peer &&
|
|
122
|
+
!b.match.guildId &&
|
|
123
|
+
!b.match.teamId);
|
|
124
|
+
if (!hasPublicCatchAll) {
|
|
125
|
+
newBindings.push({
|
|
126
|
+
agentId: publicAgentId,
|
|
127
|
+
match: {
|
|
128
|
+
channel: "whatsapp",
|
|
129
|
+
...(accountId ? { accountId } : {}),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
console.log(`[web] ensurePairedAdminBinding: created catch-all binding for ${publicAgentId} (account=${effectiveAccount})`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
104
135
|
const updatedCfg = {
|
|
105
136
|
...cfg,
|
|
106
|
-
bindings: [...bindings,
|
|
137
|
+
bindings: [...bindings, ...newBindings],
|
|
107
138
|
};
|
|
108
139
|
await writeConfigFile(updatedCfg);
|
|
109
140
|
console.log(`[web] ensurePairedAdminBinding: created paired binding for ${adminAgentId} → ${selfPhone} (account=${effectiveAccount})`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
1
|
+
import { resolveDefaultAgentId, listAgentIds } from "../agents/agent-scope.js";
|
|
2
2
|
import { listBindings } from "./bindings.js";
|
|
3
3
|
import { buildAgentMainSessionKey, buildAgentPeerSessionKey, DEFAULT_ACCOUNT_ID, DEFAULT_MAIN_KEY, normalizeAgentId, sanitizeAgentId, } from "./session-key.js";
|
|
4
4
|
export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js";
|
|
@@ -136,5 +136,28 @@ export function resolveAgentRoute(input) {
|
|
|
136
136
|
const anyAccountMatch = bindings.find((b) => b.match?.accountId?.trim() === "*" && !b.match?.peer && !b.match?.guildId && !b.match?.teamId);
|
|
137
137
|
if (anyAccountMatch)
|
|
138
138
|
return choose(anyAccountMatch.agentId, "binding.channel");
|
|
139
|
-
|
|
139
|
+
// Admin agents should only be reached via explicit peer binding.
|
|
140
|
+
// When falling to default for a DM, prefer a public counterpart so that
|
|
141
|
+
// unbound phone numbers never route to admin.
|
|
142
|
+
const defaultId = resolveDefaultAgentId(input.cfg);
|
|
143
|
+
if (peer) {
|
|
144
|
+
const publicFallback = resolvePublicFallback(input.cfg, defaultId);
|
|
145
|
+
if (publicFallback)
|
|
146
|
+
return choose(publicFallback, "default");
|
|
147
|
+
}
|
|
148
|
+
return choose(defaultId, "default");
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* When the default agent is an admin agent and a public counterpart exists,
|
|
152
|
+
* return the public agent ID. This prevents unbound DMs from reaching admin.
|
|
153
|
+
*/
|
|
154
|
+
function resolvePublicFallback(cfg, defaultId) {
|
|
155
|
+
const lower = defaultId.toLowerCase();
|
|
156
|
+
const isAdmin = lower === "admin" || lower.endsWith("-admin");
|
|
157
|
+
if (!isAdmin)
|
|
158
|
+
return undefined;
|
|
159
|
+
const allIds = listAgentIds(cfg);
|
|
160
|
+
// "foo-admin" → "foo-public"; "admin" → "public"
|
|
161
|
+
const publicId = lower === "admin" ? "public" : defaultId.replace(/-admin$/i, "-public");
|
|
162
|
+
return allIds.find((id) => id.toLowerCase() === publicId.toLowerCase());
|
|
140
163
|
}
|
package/dist/web/auth-store.js
CHANGED
|
@@ -107,8 +107,17 @@ async function clearLegacyBaileysAuthState(authDir) {
|
|
|
107
107
|
export async function logoutWeb(params) {
|
|
108
108
|
const runtime = params.runtime ?? defaultRuntime;
|
|
109
109
|
const resolvedAuthDir = resolveUserPath(params.authDir ?? resolveDefaultWebAuthDir());
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// Check whether the auth directory exists on disk — not whether creds are
|
|
111
|
+
// valid JSON. Corrupt or partial state must still be cleaned up.
|
|
112
|
+
let dirExists = false;
|
|
113
|
+
try {
|
|
114
|
+
await fs.access(resolvedAuthDir);
|
|
115
|
+
dirExists = true;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// directory doesn't exist
|
|
119
|
+
}
|
|
120
|
+
if (!dirExists) {
|
|
112
121
|
runtime.log(info("No WhatsApp Web session found; nothing to delete."));
|
|
113
122
|
return false;
|
|
114
123
|
}
|
package/dist/web/login-qr.js
CHANGED
|
@@ -79,14 +79,14 @@ export async function startWebLoginWithQr(opts = {}) {
|
|
|
79
79
|
message: `WhatsApp is already linked (${who}). Say "relink" if you want a fresh QR.`,
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
82
|
+
// Always clear credentials before generating a new QR. Even when
|
|
83
|
+
// webAuthExists() returns false, corrupt or partial files may remain on disk
|
|
84
|
+
// and cause Baileys to generate QR codes that WhatsApp rejects.
|
|
85
|
+
await logoutWeb({
|
|
86
|
+
authDir: account.authDir,
|
|
87
|
+
isLegacyAuthDir: account.isLegacyAuthDir,
|
|
88
|
+
runtime,
|
|
89
|
+
});
|
|
90
90
|
const existing = activeLogins.get(account.accountId);
|
|
91
91
|
if (existing && isLoginFresh(existing) && existing.qrDataUrl) {
|
|
92
92
|
return {
|
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -42,6 +42,16 @@ case "$OS" in
|
|
|
42
42
|
esac
|
|
43
43
|
echo "Platform: $PLATFORM ($OS)"
|
|
44
44
|
|
|
45
|
+
# ── Helper: run a command as root (uses sudo if not already root) ────────────
|
|
46
|
+
|
|
47
|
+
as_root() {
|
|
48
|
+
if [ "$(id -u)" = "0" ]; then
|
|
49
|
+
"$@"
|
|
50
|
+
else
|
|
51
|
+
sudo "$@"
|
|
52
|
+
fi
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
# ── Check Node.js ────────────────────────────────────────────────────────────
|
|
46
56
|
|
|
47
57
|
REQUIRED_NODE_MAJOR=22
|
|
@@ -68,8 +78,8 @@ install_node() {
|
|
|
68
78
|
echo "Installing Node.js v${REQUIRED_NODE_MAJOR}..."
|
|
69
79
|
if [ "$PLATFORM" = "linux" ]; then
|
|
70
80
|
if command -v apt-get >/dev/null 2>&1; then
|
|
71
|
-
curl -fsSL "https://deb.nodesource.com/setup_${REQUIRED_NODE_MAJOR}.x" |
|
|
72
|
-
|
|
81
|
+
curl -fsSL "https://deb.nodesource.com/setup_${REQUIRED_NODE_MAJOR}.x" | as_root bash -
|
|
82
|
+
as_root apt-get install -y nodejs
|
|
73
83
|
else
|
|
74
84
|
echo "Unsupported Linux package manager. Install Node.js v${REQUIRED_NODE_MAJOR}+ manually."
|
|
75
85
|
exit 1
|
|
@@ -99,11 +109,7 @@ fi
|
|
|
99
109
|
|
|
100
110
|
echo ""
|
|
101
111
|
echo "Installing Taskmaster..."
|
|
102
|
-
|
|
103
|
-
npm install -g @rubytech/taskmaster
|
|
104
|
-
else
|
|
105
|
-
sudo npm install -g @rubytech/taskmaster
|
|
106
|
-
fi
|
|
112
|
+
as_root npm install -g @rubytech/taskmaster
|
|
107
113
|
|
|
108
114
|
# Verify
|
|
109
115
|
if ! command -v taskmaster >/dev/null 2>&1; then
|
|
@@ -114,87 +120,87 @@ fi
|
|
|
114
120
|
|
|
115
121
|
echo "Taskmaster: $(taskmaster --version 2>/dev/null || echo 'installed')"
|
|
116
122
|
|
|
117
|
-
# ──
|
|
123
|
+
# ── Linux platform setup (hostname, mDNS, linger) ───────────────────────────
|
|
118
124
|
|
|
119
|
-
echo ""
|
|
120
|
-
PROVISION_ARGS=""
|
|
121
|
-
if [ -n "$PORT" ]; then
|
|
122
|
-
PROVISION_ARGS="--port $PORT"
|
|
123
|
-
fi
|
|
124
|
-
|
|
125
|
-
REAL_USER="${SUDO_USER:-$(whoami)}"
|
|
126
125
|
MDNS_PORT="${PORT:-18789}"
|
|
127
126
|
|
|
128
|
-
if [ "$
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
# to run as the real user so paths resolve to their home and systemctl
|
|
132
|
-
# --user has a D-Bus session.
|
|
133
|
-
|
|
134
|
-
if [ "$PLATFORM" = "linux" ]; then
|
|
135
|
-
echo "Platform setup..."
|
|
127
|
+
if [ "$PLATFORM" = "linux" ]; then
|
|
128
|
+
echo ""
|
|
129
|
+
echo "Platform setup..."
|
|
136
130
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
131
|
+
# Avahi / mDNS
|
|
132
|
+
as_root apt-get install -y avahi-daemon avahi-utils >/dev/null 2>&1 \
|
|
133
|
+
&& echo " avahi-daemon installed" \
|
|
134
|
+
|| echo " avahi-daemon install failed (continuing)"
|
|
141
135
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
136
|
+
# Hostname — include port to avoid conflicts with other instances on the network
|
|
137
|
+
if [ "$MDNS_PORT" = "18789" ]; then
|
|
138
|
+
TM_HOSTNAME="taskmaster"
|
|
139
|
+
else
|
|
140
|
+
TM_HOSTNAME="taskmaster-${MDNS_PORT}"
|
|
141
|
+
fi
|
|
142
|
+
as_root hostnamectl set-hostname "$TM_HOSTNAME" 2>/dev/null \
|
|
143
|
+
&& echo " hostname set to '${TM_HOSTNAME}'" \
|
|
144
|
+
|| echo " hostname set failed (continuing)"
|
|
145
|
+
|
|
146
|
+
# Ensure /etc/hosts resolves the new hostname (sudo warns otherwise).
|
|
147
|
+
# Raspberry Pi OS uses 127.0.1.1 for the hostname; other distros may use
|
|
148
|
+
# 127.0.0.1. We update an existing 127.0.1.1 line if present (replacing
|
|
149
|
+
# the old hostname like "raspberrypi"), otherwise append a new entry.
|
|
150
|
+
if ! grep -q "$TM_HOSTNAME" /etc/hosts 2>/dev/null; then
|
|
151
|
+
if grep -q "^127\.0\.1\.1" /etc/hosts 2>/dev/null; then
|
|
152
|
+
as_root sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t$TM_HOSTNAME/" /etc/hosts
|
|
145
153
|
else
|
|
146
|
-
TM_HOSTNAME
|
|
147
|
-
fi
|
|
148
|
-
hostnamectl set-hostname "$TM_HOSTNAME" 2>/dev/null \
|
|
149
|
-
&& echo " hostname set to '${TM_HOSTNAME}'" \
|
|
150
|
-
|| echo " hostname set failed (continuing)"
|
|
151
|
-
|
|
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.
|
|
156
|
-
if ! grep -q "$TM_HOSTNAME" /etc/hosts 2>/dev/null; then
|
|
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
|
|
154
|
+
echo "127.0.1.1 $TM_HOSTNAME" | as_root tee -a /etc/hosts >/dev/null
|
|
162
155
|
fi
|
|
156
|
+
fi
|
|
163
157
|
|
|
164
|
-
|
|
165
|
-
|
|
158
|
+
# Remove stale avahi service file from previous installs (conflicts with gateway Bonjour)
|
|
159
|
+
as_root rm -f /etc/avahi/services/taskmaster.service
|
|
166
160
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
161
|
+
# Restart avahi so it picks up the new hostname
|
|
162
|
+
as_root systemctl restart avahi-daemon 2>/dev/null || true
|
|
163
|
+
echo " mDNS: ${TM_HOSTNAME}.local ready"
|
|
170
164
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
165
|
+
# Tailscale (for optional internet access via Funnel)
|
|
166
|
+
if ! command -v tailscale >/dev/null 2>&1; then
|
|
167
|
+
curl -fsSL https://tailscale.com/install.sh | sh >/dev/null 2>&1 \
|
|
168
|
+
&& echo " tailscale installed" \
|
|
169
|
+
|| echo " tailscale install failed (continuing)"
|
|
170
|
+
else
|
|
171
|
+
echo " tailscale already installed"
|
|
172
|
+
fi
|
|
179
173
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
174
|
+
# Enable user services so systemctl --user works after logout
|
|
175
|
+
REAL_USER="${SUDO_USER:-$(whoami)}"
|
|
176
|
+
REAL_UID=$(id -u "$REAL_USER")
|
|
177
|
+
as_root loginctl enable-linger "$REAL_USER" 2>/dev/null || true
|
|
178
|
+
as_root systemctl start "user@${REAL_UID}.service" 2>/dev/null || true
|
|
179
|
+
|
|
180
|
+
# Wait for user D-Bus session bus
|
|
181
|
+
for _i in $(seq 1 10); do
|
|
182
|
+
[ -S "/run/user/$REAL_UID/bus" ] && break
|
|
183
|
+
sleep 0.5
|
|
184
|
+
done
|
|
185
|
+
fi
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
# ── Run provision ────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
echo ""
|
|
190
|
+
PROVISION_ARGS=""
|
|
191
|
+
if [ -n "$PORT" ]; then
|
|
192
|
+
PROVISION_ARGS="--port $PORT"
|
|
193
|
+
fi
|
|
191
194
|
|
|
192
|
-
|
|
195
|
+
REAL_USER="${SUDO_USER:-$(whoami)}"
|
|
196
|
+
|
|
197
|
+
if [ "$(id -u)" = "0" ] && [ "$REAL_USER" != "root" ]; then
|
|
198
|
+
# Running as root via sudo — provision must run as the real user so paths
|
|
199
|
+
# resolve to their home and systemctl --user has a D-Bus session.
|
|
193
200
|
REAL_HOME=$(getent passwd "$REAL_USER" 2>/dev/null | cut -d: -f6)
|
|
194
201
|
[ -z "$REAL_HOME" ] && REAL_HOME="/home/$REAL_USER"
|
|
195
202
|
REAL_UID=$(id -u "$REAL_USER")
|
|
196
203
|
|
|
197
|
-
# Run provision as the real user
|
|
198
204
|
# shellcheck disable=SC2086
|
|
199
205
|
sudo -u "$REAL_USER" \
|
|
200
206
|
HOME="$REAL_HOME" \
|