@rubytech/create-maxy-code 0.1.287 → 0.1.288
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/__tests__/cloudflared-slice.test.js +3 -2
- package/dist/__tests__/cloudflared-version-pin.test.js +24 -0
- package/dist/index.js +12 -4
- package/dist/port-resolution.js +4 -3
- package/dist/uninstall.js +11 -10
- package/package.json +1 -1
- package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +8 -4
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +18 -2
- package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +47 -10
- package/payload/platform/scripts/resume-tunnel.sh +45 -28
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Task 602 — cloudflared.slice unit emitted at install time.
|
|
2
|
-
// Every cloudflared-<brand>.
|
|
2
|
+
// Every cloudflared-<brand>.service spawned by resume-tunnel.sh drops under
|
|
3
3
|
// this slice for cohort observability via `systemctl --user status cloudflared.slice`.
|
|
4
|
+
// (Task 757 changed the per-brand unit from a scope to a supervised service.)
|
|
4
5
|
import test from "node:test";
|
|
5
6
|
import assert from "node:assert/strict";
|
|
6
7
|
import { buildCloudflaredSliceUnitFile } from "../port-resolution.js";
|
|
7
8
|
test("buildCloudflaredSliceUnitFile emits a valid systemd slice unit", () => {
|
|
8
9
|
const unit = buildCloudflaredSliceUnitFile();
|
|
9
10
|
assert.match(unit, /^\[Unit\]$/m);
|
|
10
|
-
assert.match(unit, /^Description=Cloudflare tunnel
|
|
11
|
+
assert.match(unit, /^Description=Cloudflare tunnel connectors \(Task 602\)$/m);
|
|
11
12
|
assert.match(unit, /^\[Slice\]$/m);
|
|
12
13
|
assert.match(unit, /^\[Install\]$/m);
|
|
13
14
|
assert.match(unit, /^WantedBy=default\.target$/m);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Task 757 — the cloudflared binary version is installer-owned and pinned.
|
|
2
|
+
// The connector runs with --no-autoupdate (resume-tunnel.sh), so it never
|
|
3
|
+
// self-replaces; the only way the binary version moves is bumping the pin in
|
|
4
|
+
// installCloudflared and republishing the installer. This guards against a
|
|
5
|
+
// regression back to the `releases/latest/download/` URL that let the binary
|
|
6
|
+
// drift (the 2026-06-10 realagent-code outage root cause).
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
// The test runner compiles src -> dist and runs dist/__tests__/*.test.js, so
|
|
13
|
+
// read the compiled sibling index.js; the URL string literal survives tsc.
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const compiled = readFileSync(join(here, "../index.js"), "utf8");
|
|
16
|
+
test("cloudflared version is pinned to 2026.6.0", () => {
|
|
17
|
+
assert.match(compiled, /CLOUDFLARED_VERSION\s*=\s*"2026\.6\.0"/);
|
|
18
|
+
});
|
|
19
|
+
test("the .deb fetch URL is wired to the pinned version constant", () => {
|
|
20
|
+
assert.match(compiled, /releases\/download\/\$\{CLOUDFLARED_VERSION\}\/cloudflared-linux-/);
|
|
21
|
+
});
|
|
22
|
+
test("cloudflared .deb fetch no longer uses the drifting latest URL", () => {
|
|
23
|
+
assert.doesNotMatch(compiled, /releases\/latest\/download\/cloudflared-linux-/);
|
|
24
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1599,6 +1599,13 @@ function installUv() {
|
|
|
1599
1599
|
console.error(" WARNING: uv installed but uvx not on PATH — check $HOME/.local/bin");
|
|
1600
1600
|
}
|
|
1601
1601
|
}
|
|
1602
|
+
// Task 757 — cloudflared version is installer-owned and pinned. The connector
|
|
1603
|
+
// runs with --no-autoupdate (resume-tunnel.sh), so it never self-replaces; the
|
|
1604
|
+
// only way the binary version moves is bumping this pin and republishing the
|
|
1605
|
+
// installer. Fetching `latest` (the prior behaviour) let the binary drift at
|
|
1606
|
+
// install time and, combined with the connector's own auto-update, caused the
|
|
1607
|
+
// 2026-06-10 realagent-code tunnel outage.
|
|
1608
|
+
const CLOUDFLARED_VERSION = "2026.6.0";
|
|
1602
1609
|
function installCloudflared() {
|
|
1603
1610
|
if (commandExists("cloudflared")) {
|
|
1604
1611
|
log("6", TOTAL, "Cloudflared already installed.");
|
|
@@ -1619,7 +1626,7 @@ function installCloudflared() {
|
|
|
1619
1626
|
}
|
|
1620
1627
|
const arch = isArm64() ? "arm64" : "amd64";
|
|
1621
1628
|
const debPath = "/tmp/cloudflared.deb";
|
|
1622
|
-
shellRetry("curl", ["-fSL", "--progress-bar", `https://github.com/cloudflare/cloudflared/releases/
|
|
1629
|
+
shellRetry("curl", ["-fSL", "--progress-bar", `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-${arch}.deb`, "-o", debPath], { timeout: 120_000 }, 3, 10);
|
|
1623
1630
|
console.log(" [privileged] dpkg -i");
|
|
1624
1631
|
shell("dpkg", ["-i", debPath], { sudo: true });
|
|
1625
1632
|
spawnSync("rm", ["-f", debPath]);
|
|
@@ -3180,11 +3187,12 @@ function installService() {
|
|
|
3180
3187
|
writeFileSync(join(serviceDir, claudePtysSliceName), buildClaudePtysSliceUnitFile());
|
|
3181
3188
|
logFile(` ${claudePtysSliceName}: Task 250 slice for claude-session-*.scope cohort`);
|
|
3182
3189
|
// Task 602 — write the brand-agnostic `cloudflared.slice` unit. Every
|
|
3183
|
-
// cloudflared-<brand>.
|
|
3184
|
-
// slice
|
|
3190
|
+
// cloudflared-<brand>.service spawned by resume-tunnel.sh drops under this
|
|
3191
|
+
// slice (Task 757 changed the unit from a scope to a supervised service).
|
|
3192
|
+
// Writing once per install is idempotent; daemon-reload below picks it up.
|
|
3185
3193
|
const cloudflaredSliceName = "cloudflared.slice";
|
|
3186
3194
|
writeFileSync(join(serviceDir, cloudflaredSliceName), buildCloudflaredSliceUnitFile());
|
|
3187
|
-
logFile(` ${cloudflaredSliceName}: Task 602 slice for cloudflared-*.
|
|
3195
|
+
logFile(` ${cloudflaredSliceName}: Task 602 slice for cloudflared-*.service cohort`);
|
|
3188
3196
|
// Task 600 — per-brand RSS sampler: a long-running user service that
|
|
3189
3197
|
// periodically writes per-claude.exe RSS + aggregate RSS to disk.
|
|
3190
3198
|
// Named per-brand (BRAND.hostname prefix) so two brands on the same
|
package/dist/port-resolution.js
CHANGED
|
@@ -187,16 +187,17 @@ WantedBy=default.target
|
|
|
187
187
|
`;
|
|
188
188
|
}
|
|
189
189
|
// ---------------------------------------------------------------------------
|
|
190
|
-
// Task 602 — `cloudflared.slice` unit. Every cloudflared-<brand>.
|
|
190
|
+
// Task 602 — `cloudflared.slice` unit. Every cloudflared-<brand>.service
|
|
191
191
|
// spawned by resume-tunnel.sh drops under this slice for one-glance cohort
|
|
192
|
-
// observability (`systemctl --user status cloudflared.slice`).
|
|
192
|
+
// observability (`systemctl --user status cloudflared.slice`). Task 757
|
|
193
|
+
// changed the per-brand unit from a transient scope to a supervised service.
|
|
193
194
|
//
|
|
194
195
|
// Brand-agnostic: one slice across all co-resident brands, mirroring the
|
|
195
196
|
// Task-250 claude-ptys.slice precedent.
|
|
196
197
|
// ---------------------------------------------------------------------------
|
|
197
198
|
export function buildCloudflaredSliceUnitFile() {
|
|
198
199
|
return `[Unit]
|
|
199
|
-
Description=Cloudflare tunnel
|
|
200
|
+
Description=Cloudflare tunnel connectors (Task 602)
|
|
200
201
|
Documentation=https://github.com/rubytech/maxy-code/blob/main/.tasks/archive/602-cloudflared-tunnel-must-survive-brand-service-restart-via-systemd-scope.md
|
|
201
202
|
Before=shutdown.target
|
|
202
203
|
|
package/dist/uninstall.js
CHANGED
|
@@ -181,24 +181,25 @@ function stopServices() {
|
|
|
181
181
|
catch {
|
|
182
182
|
console.log(` ${neo4jService} not running`);
|
|
183
183
|
}
|
|
184
|
-
// Stop the cloudflared
|
|
185
|
-
//
|
|
186
|
-
//
|
|
184
|
+
// Stop the cloudflared service for this brand (Task 757 — the connector now
|
|
185
|
+
// runs as a supervised cloudflared-<brand>.service, not a transient scope).
|
|
186
|
+
// The unit name mirrors what resume-tunnel.sh derives: strip leading dot from
|
|
187
|
+
// configDir. BRAND.configDir is ".maxy-code"; strip the dot → "maxy-code".
|
|
187
188
|
// --no-block: uninstall does not wait for graceful shutdown of the tunnel.
|
|
188
|
-
// Exit 5 (no such unit) =
|
|
189
|
+
// Exit 5 (no such unit) = service absent = success.
|
|
189
190
|
const brand = BRAND.configDir.replace(/^\./, "");
|
|
190
|
-
const
|
|
191
|
-
const stopResult = spawnSync("systemctl", ["--user", "stop", "--no-block",
|
|
191
|
+
const serviceUnit = `cloudflared-${brand}.service`;
|
|
192
|
+
const stopResult = spawnSync("systemctl", ["--user", "stop", "--no-block", serviceUnit], { stdio: "pipe" });
|
|
192
193
|
if (stopResult.status === 0) {
|
|
193
|
-
console.log(` Stopped ${
|
|
194
|
+
console.log(` Stopped ${serviceUnit}`);
|
|
194
195
|
}
|
|
195
196
|
else if (stopResult.status === 5) {
|
|
196
|
-
// exit 5 = unit not found —
|
|
197
|
-
console.log(` ${
|
|
197
|
+
// exit 5 = unit not found — service already absent, which is expected
|
|
198
|
+
console.log(` ${serviceUnit} not running (service absent)`);
|
|
198
199
|
}
|
|
199
200
|
else {
|
|
200
201
|
// Any other non-zero exit means systemctl itself failed (e.g. DBus unavailable)
|
|
201
|
-
console.log(` systemctl stop ${
|
|
202
|
+
console.log(` systemctl stop ${serviceUnit} exited ${stopResult.status} — service may still be running`);
|
|
202
203
|
}
|
|
203
204
|
// Brand isolation: the VNC stack and Ollama daemon are
|
|
204
205
|
// device-wide singletons. Killing them during uninstall would interrupt
|
package/package.json
CHANGED
|
@@ -422,7 +422,7 @@ Before persisting, **read every rendered page** of `base.pdf` — full-bleed pag
|
|
|
422
422
|
|
|
423
423
|
## 6. Sweep + dispatch
|
|
424
424
|
|
|
425
|
-
|
|
425
|
+
Dispatch runs **only** inside a live operator session on the account's own device — when the operator asks for new acceptances, or when an in-session watcher running inside a still-live session performs the sweep. There is no off-device dispatcher; never register a Claude Code `/schedule` routine or any headless/cloud scheduler to run it (§ 8.2 says why). Mint one **D1-Edit** token here — `${MINTED_D1_EDIT}` — per `api.md` § Minting a narrow token, resolving the permission-group id as `d1-data-capture.md` § 0 does. Reads use the Edit token too: the D1 **query** endpoint rejects a D1-Read token (`d1-data-capture.md`), so a separate read-scoped token is not minted.
|
|
426
426
|
|
|
427
427
|
1. **Read the unswept rows** with `${MINTED_D1_EDIT}`:
|
|
428
428
|
|
|
@@ -530,16 +530,20 @@ The flow has no application logging — the signal surface is the **D1 row state
|
|
|
530
530
|
|
|
531
531
|
Signal fires *without reproducing end to end*: the test POST returns non-`ok`, and the confirming `SELECT WHERE swept = 0` shows no new row. Fix: re-mint the token confirming **both** Pages Edit and D1 Edit (`d1-data-capture.md` § 0). First thing to check when acceptances stop arriving.
|
|
532
532
|
|
|
533
|
-
### 8.2 Acceptance recorded but never dispatched (no-event) — the
|
|
533
|
+
### 8.2 Acceptance recorded but never dispatched (no-event) — the in-session reconciliation check
|
|
534
534
|
|
|
535
|
-
The sweep never ran, or `email-send` failed after the row existed — no log, because no action was taken. The
|
|
535
|
+
The sweep never ran, or `email-send` failed after the row existed — no log, because no action was taken. **Capture is durable; dispatch is not.** The acceptance is recorded at the **edge** in D1 (intro / § 1a) the instant the signer submits — nothing is ever lost, whether or not a session is running. **Dispatch** (stamp + email) needs the account's Pi-local tooling — the D1-Edit token, `email-send`, `stamp.mjs`, `ACCOUNT_DIR` — so it happens **only** while an operator session is live. An acceptance captured while no session is running sits **unswept** until the next session runs the sweep; latency is bounded by operator session cadence, not a cron. That is a deliberate limitation of the in-session-only model, not a defect — the durable D1 row guarantees no loss. The standing check is the operator's **session-open / before-close ritual**: run the reconciliation inside a live session, and any aged unswept row is the signal to sweep now.
|
|
536
|
+
|
|
537
|
+
> Dispatch runs **only** inside a live operator session on the account's own device. Never register a Claude Code `/schedule` routine, a `RemoteTrigger`, a `CronCreate` durable job, or any off-device/headless scheduler to run the sweep. `/schedule` spawns a fresh agent on **claude.ai's cloud**, which has none of this account's D1 token, `email-send`, `stamp.mjs`, or `ACCOUNT_DIR` — it physically cannot stamp or send. It is a Claude Code capability, not a Maxy one, and it is not a substitute for the in-session sweep. Monitoring and dispatch happen in-session, while the session is live. That is the only path.
|
|
538
|
+
|
|
539
|
+
The reconciliation query, run inside a live session at open and before close:
|
|
536
540
|
|
|
537
541
|
```bash
|
|
538
542
|
CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
|
|
539
543
|
"SELECT count(*), min(accepted_at) FROM acceptances WHERE swept = 0;"
|
|
540
544
|
```
|
|
541
545
|
|
|
542
|
-
Any row whose `accepted_at` is older than the
|
|
546
|
+
Any row whose `accepted_at` is older than the operator's session cadence is an undispatched acceptance — the failure signal. Because § 6 flips `swept` only after both sends verify (and a text-only degrade is treated as a failed send, § 0 item 3), a failed or degraded dispatch leaves the row here for the next pass.
|
|
543
547
|
|
|
544
548
|
### 8.3 Document modified after an acceptance, or base render missing (signed-content drift)
|
|
545
549
|
|
|
@@ -226,6 +226,7 @@ Only if that line prints nothing (all vars set) do you proceed to write the conf
|
|
|
226
226
|
cat > "${CFG_DIR}/config.yml" <<EOF
|
|
227
227
|
tunnel: ${TUNNEL_ID}
|
|
228
228
|
credentials-file: ${CFG_DIR}/${TUNNEL_ID}.json
|
|
229
|
+
no-autoupdate: true
|
|
229
230
|
ingress:
|
|
230
231
|
- hostname: admin.maxy.bot
|
|
231
232
|
service: http://localhost:${PORT}
|
|
@@ -235,7 +236,7 @@ ingress:
|
|
|
235
236
|
EOF
|
|
236
237
|
```
|
|
237
238
|
|
|
238
|
-
**Why:** Tells cloudflared at startup which tunnel to run, where its credentials are, and how to route each hostname to the brand's local service port. The trailing `http_status:404` line is the mandatory catch-all.
|
|
239
|
+
**Why:** Tells cloudflared at startup which tunnel to run, where its credentials are, and how to route each hostname to the brand's local service port. The trailing `http_status:404` line is the mandatory catch-all. `no-autoupdate: true` stops the connector downloading a new binary and replacing itself (which exits the running process); the cloudflared version is installer-owned and bumped deliberately. This is belt-and-braces with the `--no-autoupdate` flag `resume-tunnel.sh` already passes on the spawn argv — the flag covers every existing install on its next service restart, this line covers a connector started any other way from a freshly-provisioned config. Task 757.
|
|
239
240
|
|
|
240
241
|
**Success:** Confirm with:
|
|
241
242
|
|
|
@@ -425,7 +426,22 @@ A non-530 response means the tunnel is live.
|
|
|
425
426
|
|
|
426
427
|
**Do NOT use `sudo cloudflared service install`.** It writes `/etc/systemd/system/cloudflared.service`, copies your config to `/etc/cloudflared/config.yml` (a system-wide path), and runs the connector as root — breaking brand isolation on any device that hosts more than one brand under the same Linux user. We tried this path on 2026-04-19; it created a duplicate connector parallel to the existing user-space one and required an explicit uninstall to undo.
|
|
427
428
|
|
|
428
|
-
The correct production pattern is already in place on every Maxy device: the brand's platform UI itself is a **user-space systemd service** at `~/.config/systemd/user/<brand>.service`, and that service spawns the cloudflared connector as an `ExecStartPre=` via `platform/scripts/resume-tunnel.sh`.
|
|
429
|
+
The correct production pattern is already in place on every Maxy device: the brand's platform UI itself is a **user-space systemd service** at `~/.config/systemd/user/<brand>.service`, and that service spawns the cloudflared connector as an `ExecStartPre=` via `platform/scripts/resume-tunnel.sh`. The connector runs as the Linux user (not root), its config is read from `${CFG_DIR}/config.yml` (brand-scoped, never `/etc`), and multiple brands coexist because each has its own unit file (`maxy.service`, `realagent.service`, etc.).
|
|
430
|
+
|
|
431
|
+
**The connector is a supervised, self-healing unit (Task 602 + Task 757).** `resume-tunnel.sh` does not run cloudflared as a child of the brand service. It spawns it into its own transient systemd **service** — `cloudflared-<brand>.service` under `cloudflared.slice` — with `Restart=always`. Two consequences: a brand-service restart no longer reaps the connector (Task 602's cgroup decoupling), and systemd resurrects the connector on **any** exit — crash, OOM, manual kill (Task 757). The connector also runs with `--no-autoupdate`, so it never downloads a new binary and replaces itself; the version is installer-owned (pinned in the installer, bumped deliberately). `StartLimitIntervalSec=300`/`StartLimitBurst=5` mean a genuinely broken binary surfaces as a `failed` unit instead of looping forever.
|
|
432
|
+
|
|
433
|
+
**Observability.** A connector death is now a logged, countable event, not an invisible gap:
|
|
434
|
+
|
|
435
|
+
```
|
|
436
|
+
# Every restart systemd performs is journaled for the connector's own unit:
|
|
437
|
+
journalctl --user -u cloudflared-<brand>.service | grep "Scheduled restart job"
|
|
438
|
+
# (each line: "Scheduled restart job, restart counter is at N")
|
|
439
|
+
|
|
440
|
+
# Confirm auto-update is off — cloudflared prints its effective settings at startup:
|
|
441
|
+
grep "no-autoupdate:true" "${CFG_DIR}/logs/cloudflared.log"
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
`no-autoupdate:true` in cloudflared's startup `Settings:` line is the per-box rollout acceptance signal. A unit that has tripped its start limit shows `Active: failed` under `systemctl --user status cloudflared-<brand>.service` — investigate the binary rather than restarting blindly.
|
|
429
445
|
|
|
430
446
|
**Durability prerequisites (one-time per device):**
|
|
431
447
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Tests for resume-tunnel.sh
|
|
2
|
+
# Tests for resume-tunnel.sh supervised-service spawn path (Task 602 + Task 757).
|
|
3
|
+
# Task 602 decoupled the connector into its own transient unit under
|
|
4
|
+
# cloudflared.slice; Task 757 changed that unit from a --scope (no restart
|
|
5
|
+
# policy) to a --service with Restart=always and --no-autoupdate.
|
|
3
6
|
# Stubs systemd-run, systemctl, and cloudflared binaries via a fake $PATH entry.
|
|
4
7
|
set -uo pipefail
|
|
5
8
|
|
|
@@ -80,10 +83,12 @@ exit 0
|
|
|
80
83
|
SH
|
|
81
84
|
chmod +x "$bin_dir/systemctl"
|
|
82
85
|
|
|
83
|
-
# Stub systemd-run:
|
|
86
|
+
# Stub systemd-run: echoes every arg (so tests can assert the unit name, slice,
|
|
87
|
+
# restart properties, and the connector argv), optionally writes connection line.
|
|
84
88
|
cat > "$bin_dir/systemd-run" <<SH
|
|
85
89
|
#!/bin/bash
|
|
86
90
|
for arg in "\$@"; do
|
|
91
|
+
echo "[stub] arg=\$arg"
|
|
87
92
|
case "\$arg" in
|
|
88
93
|
--unit=*) echo "[stub] unit=\${arg#--unit=}" ;;
|
|
89
94
|
--slice=*) echo "[stub] slice=\${arg#--slice=}" ;;
|
|
@@ -120,7 +125,7 @@ case_skip_when_scope_active() {
|
|
|
120
125
|
if [[ $rc -ne 0 ]]; then
|
|
121
126
|
echo " expected exit 0, got $rc"; return 1
|
|
122
127
|
fi
|
|
123
|
-
if ! echo "$log_content" | grep -q "already running.*
|
|
128
|
+
if ! echo "$log_content" | grep -q "already running.*service.*cloudflared-maxy-code.service"; then
|
|
124
129
|
echo " skip branch missing expected log line"
|
|
125
130
|
echo "$log_content"
|
|
126
131
|
return 1
|
|
@@ -149,8 +154,8 @@ case_spawn_succeeds() {
|
|
|
149
154
|
if [[ $rc -ne 0 ]]; then
|
|
150
155
|
echo " expected exit 0, got $rc"; return 1
|
|
151
156
|
fi
|
|
152
|
-
if ! echo "$log_content" | grep -q "spawned
|
|
153
|
-
echo " missing spawned
|
|
157
|
+
if ! echo "$log_content" | grep -q "spawned service=cloudflared-maxy-code.service"; then
|
|
158
|
+
echo " missing spawned service log line"; echo "$log_content"; return 1
|
|
154
159
|
fi
|
|
155
160
|
if ! echo "$log_content" | grep -q "tunnel verified"; then
|
|
156
161
|
echo " missing tunnel verified log line"; echo "$log_content"; return 1
|
|
@@ -186,7 +191,7 @@ case_spawn_no_connection() {
|
|
|
186
191
|
return 0
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
# CASE 4:
|
|
194
|
+
# CASE 4: service unit name derived correctly from configDir (.real-agent → cloudflared-real-agent.service)
|
|
190
195
|
case_scope_unit_name_from_config_dir() {
|
|
191
196
|
local root
|
|
192
197
|
root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
|
|
@@ -207,8 +212,8 @@ case_scope_unit_name_from_config_dir() {
|
|
|
207
212
|
if [[ $rc -ne 0 ]]; then
|
|
208
213
|
echo " expected exit 0, got $rc"; return 1
|
|
209
214
|
fi
|
|
210
|
-
if ! echo "$log_content" | grep -q "spawned
|
|
211
|
-
echo "
|
|
215
|
+
if ! echo "$log_content" | grep -q "spawned service=cloudflared-real-agent.service"; then
|
|
216
|
+
echo " service unit name not derived correctly from .real-agent"
|
|
212
217
|
echo "$log_content"; return 1
|
|
213
218
|
fi
|
|
214
219
|
return 0
|
|
@@ -240,11 +245,43 @@ case_slice_flag_present() {
|
|
|
240
245
|
return 0
|
|
241
246
|
}
|
|
242
247
|
|
|
243
|
-
|
|
248
|
+
# CASE 6: supervision contract — Restart=always, crash-loop limits, --no-autoupdate,
|
|
249
|
+
# and the unit is a .service (Task 757), all reaching systemd-run.
|
|
250
|
+
case_supervision_contract() {
|
|
251
|
+
local root
|
|
252
|
+
root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
|
|
253
|
+
setup "$root" ".maxy-code" "inactive" "yes"
|
|
254
|
+
|
|
255
|
+
local log="$root/home/.maxy-code/logs/cloudflared.log"
|
|
256
|
+
|
|
257
|
+
HOME="$root/home" \
|
|
258
|
+
PATH="$root/bin:$PATH" \
|
|
259
|
+
MAXY_PLATFORM_ROOT="$root/platform_root" \
|
|
260
|
+
bash "$RESUME"
|
|
261
|
+
|
|
262
|
+
local log_content
|
|
263
|
+
log_content=$(cat "$log" 2>/dev/null || echo "")
|
|
264
|
+
cleanup "$root"
|
|
265
|
+
|
|
266
|
+
local missing=""
|
|
267
|
+
echo "$log_content" | grep -q "\[stub\] arg=--unit=cloudflared-maxy-code.service" || missing="$missing unit=.service"
|
|
268
|
+
echo "$log_content" | grep -q "\[stub\] arg=Restart=always" || missing="$missing Restart=always"
|
|
269
|
+
echo "$log_content" | grep -q "\[stub\] arg=StartLimitIntervalSec=300" || missing="$missing StartLimitIntervalSec"
|
|
270
|
+
echo "$log_content" | grep -q "\[stub\] arg=StartLimitBurst=5" || missing="$missing StartLimitBurst"
|
|
271
|
+
echo "$log_content" | grep -q "\[stub\] arg=--no-autoupdate" || missing="$missing --no-autoupdate"
|
|
272
|
+
if [[ -n "$missing" ]]; then
|
|
273
|
+
echo " systemd-run argv missing:$missing"
|
|
274
|
+
echo "$log_content"; return 1
|
|
275
|
+
fi
|
|
276
|
+
return 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
run_case "skip when service active" case_skip_when_scope_active
|
|
244
280
|
run_case "spawn succeeds and logs verified" case_spawn_succeeds
|
|
245
281
|
run_case "spawn with no connection logs ERROR exits 0" case_spawn_no_connection
|
|
246
|
-
run_case "
|
|
282
|
+
run_case "service unit name derived from configDir" case_scope_unit_name_from_config_dir
|
|
247
283
|
run_case "slice flag passed to systemd-run" case_slice_flag_present
|
|
284
|
+
run_case "supervision contract: restart-always + no-autoupdate + .service" case_supervision_contract
|
|
248
285
|
|
|
249
286
|
echo ""
|
|
250
287
|
echo "Results: $PASS passed, $FAIL failed"
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# resume-tunnel.sh — Resume the Cloudflare tunnel on brand-service start.
|
|
3
3
|
#
|
|
4
|
-
# Spawns cloudflared into its own transient systemd
|
|
4
|
+
# Spawns cloudflared into its own transient systemd SERVICE (cloudflared-<brand>.service)
|
|
5
5
|
# under cloudflared.slice, completely decoupled from the brand service's cgroup.
|
|
6
|
-
# A brand-service restart can no longer reap the tunnel
|
|
6
|
+
# A brand-service restart can no longer reap the tunnel (Task 602's cgroup
|
|
7
|
+
# decoupling). The unit is a Restart=always service (Task 757), so systemd
|
|
8
|
+
# resurrects the connector on ANY exit — crash, OOM, manual kill — and the
|
|
9
|
+
# connector runs with --no-autoupdate so it never replaces itself; the binary
|
|
10
|
+
# version is installer-owned. This is still the single spawner: only the unit
|
|
11
|
+
# type changed from a transient --scope (no restart policy) to a supervised
|
|
12
|
+
# --service. No second spawner is introduced.
|
|
7
13
|
#
|
|
8
14
|
# Called as ExecStartPre in maxy.service. All failures exit 0 — this script must
|
|
9
15
|
# never prevent maxy.service from starting.
|
|
@@ -49,16 +55,16 @@ if [ -z "$TUNNEL_ID" ]; then
|
|
|
49
55
|
exit 0
|
|
50
56
|
fi
|
|
51
57
|
|
|
52
|
-
# Derive
|
|
53
|
-
# .maxy-code → cloudflared-maxy-code.
|
|
58
|
+
# Derive service unit name: strip leading dot from CONFIG_DIR.
|
|
59
|
+
# .maxy-code → cloudflared-maxy-code.service
|
|
54
60
|
BRAND="${CONFIG_DIR#.}"
|
|
55
|
-
|
|
61
|
+
SERVICE_UNIT="cloudflared-${BRAND}.service"
|
|
56
62
|
|
|
57
|
-
# Liveness check: query the
|
|
58
|
-
# The
|
|
59
|
-
if systemctl --user is-active "$
|
|
60
|
-
|
|
61
|
-
log "Tunnel already running (
|
|
63
|
+
# Liveness check: query the service unit state, not a recorded PID.
|
|
64
|
+
# The service is owned by user-systemd, fully outside the brand service's cgroup.
|
|
65
|
+
if systemctl --user is-active "$SERVICE_UNIT" >/dev/null 2>&1; then
|
|
66
|
+
SERVICE_PID=$(systemctl --user show "$SERVICE_UNIT" --property=MainPID --value 2>/dev/null || echo "unknown")
|
|
67
|
+
log "Tunnel already running (service $SERVICE_UNIT active, MainPID $SERVICE_PID) — skipping resume"
|
|
62
68
|
exit 0
|
|
63
69
|
fi
|
|
64
70
|
|
|
@@ -82,26 +88,37 @@ log "Resuming tunnel $TUNNEL_ID for $DOMAIN..."
|
|
|
82
88
|
mkdir -p "$LOG_DIR"
|
|
83
89
|
LOG_OFFSET=$(wc -c < "$LOG_FILE" 2>/dev/null | awk '{print $1+0}' || echo 0)
|
|
84
90
|
|
|
85
|
-
# Spawn into a transient
|
|
86
|
-
#
|
|
87
|
-
# systemd
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
+
# Spawn into a SUPERVISED transient service under cloudflared.slice (Task 757).
|
|
92
|
+
# Unlike a --scope (no restart policy), a Restart=always service is resurrected
|
|
93
|
+
# by systemd on ANY exit — crash, OOM, manual kill. --no-autoupdate stops the
|
|
94
|
+
# connector replacing its own binary, so the version stays installer-owned.
|
|
95
|
+
# StartLimitIntervalSec/StartLimitBurst trip a genuinely broken binary to
|
|
96
|
+
# `failed` instead of looping forever (5 starts within 300s → failed).
|
|
97
|
+
# A service's stdout/stderr go to the journal, not this shell, so route them to
|
|
98
|
+
# LOG_FILE via StandardOutput/StandardError=append so the verification gate
|
|
99
|
+
# below and the operator's cloudflared.log keep working. systemd-run returns
|
|
100
|
+
# once the unit is active (it does not block for the process lifetime), so there
|
|
101
|
+
# is no shell child to background. --quiet suppresses systemd-run's "Running as
|
|
102
|
+
# unit:" line; the trailing redirect only captures any error output it prints.
|
|
91
103
|
systemd-run \
|
|
92
104
|
--user \
|
|
93
|
-
--scope \
|
|
94
105
|
--quiet \
|
|
95
|
-
--unit="$
|
|
106
|
+
--unit="$SERVICE_UNIT" \
|
|
96
107
|
--slice=cloudflared \
|
|
108
|
+
-p Restart=always \
|
|
109
|
+
-p RestartSec=2 \
|
|
110
|
+
-p StartLimitIntervalSec=300 \
|
|
111
|
+
-p StartLimitBurst=5 \
|
|
97
112
|
-p TimeoutStopSec=15 \
|
|
113
|
+
-p "StandardOutput=append:$LOG_FILE" \
|
|
114
|
+
-p "StandardError=append:$LOG_FILE" \
|
|
98
115
|
-- \
|
|
99
|
-
"$CLOUDFLARED_BIN" --origincert "$CERT_PATH" --config "$CONFIG_PATH" tunnel run \
|
|
100
|
-
>> "$LOG_FILE" 2>&1
|
|
116
|
+
"$CLOUDFLARED_BIN" --no-autoupdate --origincert "$CERT_PATH" --config "$CONFIG_PATH" tunnel run \
|
|
117
|
+
>> "$LOG_FILE" 2>&1
|
|
101
118
|
|
|
102
|
-
# Log spawn receipt so the operator can distinguish the
|
|
103
|
-
|
|
104
|
-
log "spawned
|
|
119
|
+
# Log spawn receipt so the operator can distinguish the supervised path from legacy nohup.
|
|
120
|
+
SERVICE_PID=$(systemctl --user show "$SERVICE_UNIT" --property=MainPID --value 2>/dev/null || echo "unknown")
|
|
121
|
+
log "spawned service=$SERVICE_UNIT slice=cloudflared main-pid=$SERVICE_PID"
|
|
105
122
|
|
|
106
123
|
# Post-spawn connection verification gate.
|
|
107
124
|
# Polls only new log content (from LOG_OFFSET onwards) to avoid false positives
|
|
@@ -127,16 +144,16 @@ done
|
|
|
127
144
|
if [ "$VERIFIED" -eq 1 ]; then
|
|
128
145
|
ELAPSED=$(( $(date +%s) - VERIFY_START ))
|
|
129
146
|
CONN_COUNT=$(tail -c "+$((LOG_OFFSET + 1))" "$LOG_FILE" 2>/dev/null | grep -c "Registered tunnel connection" || echo "?")
|
|
130
|
-
log "tunnel verified
|
|
147
|
+
log "tunnel verified service=$SERVICE_UNIT connections=$CONN_COUNT ms=$(( ELAPSED * 1000 ))"
|
|
131
148
|
else
|
|
132
|
-
log "ERROR: tunnel
|
|
149
|
+
log "ERROR: tunnel service started but no edge connection within ${VERIFY_DEADLINE}s — service=$SERVICE_UNIT"
|
|
133
150
|
fi
|
|
134
151
|
|
|
135
|
-
# Update state file: keep pid/startedAt for observability, add
|
|
152
|
+
# Update state file: keep pid/startedAt for observability, add serviceUnit as control signal.
|
|
136
153
|
NEW_STATE=$(echo "$STATE" | jq \
|
|
137
|
-
--arg unit "$
|
|
154
|
+
--arg unit "$SERVICE_UNIT" \
|
|
138
155
|
--argjson ts "$(date +%s)000" \
|
|
139
|
-
'.
|
|
156
|
+
'.serviceUnit = $unit | .startedAt = $ts' 2>/dev/null || echo "")
|
|
140
157
|
|
|
141
158
|
if [ -n "$NEW_STATE" ] && echo "$NEW_STATE" > "$STATE_FILE.tmp" 2>/dev/null && mv "$STATE_FILE.tmp" "$STATE_FILE" 2>/dev/null; then
|
|
142
159
|
:
|