@rubytech/create-maxy-code 0.1.233 → 0.1.234

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.
@@ -0,0 +1,19 @@
1
+ // Task 602 — cloudflared.slice unit emitted at install time.
2
+ // Every cloudflared-<brand>.scope spawned by resume-tunnel.sh drops under
3
+ // this slice for cohort observability via `systemctl --user status cloudflared.slice`.
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { buildCloudflaredSliceUnitFile } from "../port-resolution.js";
7
+ test("buildCloudflaredSliceUnitFile emits a valid systemd slice unit", () => {
8
+ const unit = buildCloudflaredSliceUnitFile();
9
+ assert.match(unit, /^\[Unit\]$/m);
10
+ assert.match(unit, /^Description=Cloudflare tunnel scopes \(Task 602\)$/m);
11
+ assert.match(unit, /^\[Slice\]$/m);
12
+ assert.match(unit, /^\[Install\]$/m);
13
+ assert.match(unit, /^WantedBy=default\.target$/m);
14
+ });
15
+ test("buildCloudflaredSliceUnitFile output has no per-brand template tokens", () => {
16
+ const unit = buildCloudflaredSliceUnitFile();
17
+ assert.doesNotMatch(unit, /\$\{/);
18
+ assert.doesNotMatch(unit, /__[A-Z_]+__/);
19
+ });
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { registerSpecialistAgentsAt, SpecialistSymlinkCollision } from "./specia
5
5
  import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./permissions-seed.js";
6
6
  import { resolve, join, dirname } from "node:path";
7
7
  import { randomBytes } from "node:crypto";
8
- import { resolveInstallPortFromFs, buildMaxyUnitFile, buildClaudeSessionManagerUnitFile, buildClaudePtysSliceUnitFile } from "./port-resolution.js";
8
+ import { resolveInstallPortFromFs, buildMaxyUnitFile, buildClaudeSessionManagerUnitFile, buildClaudePtysSliceUnitFile, buildCloudflaredSliceUnitFile } from "./port-resolution.js";
9
9
  import { validateTierFlag, validateEntitlementBase64, applyTierToAccountConfig, entitlementPath } from "./tier-flag.js";
10
10
  import { parseOsRelease, isUbuntuLike as isUbuntuLikePure, parseAptCacheCandidate, decideAptResolution, } from "./apt-resolve.js";
11
11
  import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
@@ -3120,6 +3120,12 @@ function installService() {
3120
3120
  const claudePtysSliceName = "claude-ptys.slice";
3121
3121
  writeFileSync(join(serviceDir, claudePtysSliceName), buildClaudePtysSliceUnitFile());
3122
3122
  logFile(` ${claudePtysSliceName}: Task 250 slice for claude-session-*.scope cohort`);
3123
+ // Task 602 — write the brand-agnostic `cloudflared.slice` unit. Every
3124
+ // cloudflared-<brand>.scope spawned by resume-tunnel.sh drops under this
3125
+ // slice. Writing once per install is idempotent; daemon-reload below picks it up.
3126
+ const cloudflaredSliceName = "cloudflared.slice";
3127
+ writeFileSync(join(serviceDir, cloudflaredSliceName), buildCloudflaredSliceUnitFile());
3128
+ logFile(` ${cloudflaredSliceName}: Task 602 slice for cloudflared-*.scope cohort`);
3123
3129
  // Task 600 — per-brand RSS sampler: a long-running user service that
3124
3130
  // periodically writes per-claude.exe RSS + aggregate RSS to disk.
3125
3131
  // Named per-brand (BRAND.hostname prefix) so two brands on the same
@@ -186,3 +186,23 @@ Before=shutdown.target
186
186
  WantedBy=default.target
187
187
  `;
188
188
  }
189
+ // ---------------------------------------------------------------------------
190
+ // Task 602 — `cloudflared.slice` unit. Every cloudflared-<brand>.scope
191
+ // spawned by resume-tunnel.sh drops under this slice for one-glance cohort
192
+ // observability (`systemctl --user status cloudflared.slice`).
193
+ //
194
+ // Brand-agnostic: one slice across all co-resident brands, mirroring the
195
+ // Task-250 claude-ptys.slice precedent.
196
+ // ---------------------------------------------------------------------------
197
+ export function buildCloudflaredSliceUnitFile() {
198
+ return `[Unit]
199
+ Description=Cloudflare tunnel scopes (Task 602)
200
+ Documentation=https://github.com/rubytech/maxy-code/blob/main/.tasks/archive/602-cloudflared-tunnel-must-survive-brand-service-restart-via-systemd-scope.md
201
+ Before=shutdown.target
202
+
203
+ [Slice]
204
+
205
+ [Install]
206
+ WantedBy=default.target
207
+ `;
208
+ }
package/dist/uninstall.js CHANGED
@@ -181,21 +181,24 @@ function stopServices() {
181
181
  catch {
182
182
  console.log(` ${neo4jService} not running`);
183
183
  }
184
- // Kill cloudflared (read PID from this brand's tunnel.state only).
185
- // Never read ~/.cloudflared/ that path is shared with every brand installed
186
- // on this device plus cloudflared's own runtime-generated files.
187
- const brandStateFile = join(CONFIG_DIR, "cloudflared/tunnel.state");
188
- if (existsSync(brandStateFile)) {
189
- try {
190
- const state = JSON.parse(readFileSync(brandStateFile, "utf-8"));
191
- if (state.pid) {
192
- spawnSync("kill", [String(state.pid)], { stdio: "pipe" });
193
- console.log(` Killed cloudflared (PID ${state.pid})`);
194
- }
195
- }
196
- catch {
197
- console.log(" cloudflared not running");
198
- }
184
+ // Stop the cloudflared scope for this brand. The scope unit name mirrors
185
+ // what resume-tunnel.sh derives: strip leading dot from configDir.
186
+ // BRAND.configDir is ".maxy-code"; strip the dot → "maxy-code".
187
+ // --no-block: uninstall does not wait for graceful shutdown of the tunnel.
188
+ // Exit 5 (no such unit) = scope absent = success.
189
+ const brand = BRAND.configDir.replace(/^\./, "");
190
+ const scopeUnit = `cloudflared-${brand}.scope`;
191
+ const stopResult = spawnSync("systemctl", ["--user", "stop", "--no-block", scopeUnit], { stdio: "pipe" });
192
+ if (stopResult.status === 0) {
193
+ console.log(` Stopped ${scopeUnit}`);
194
+ }
195
+ else if (stopResult.status === 5) {
196
+ // exit 5 = unit not found — scope already absent, which is expected
197
+ console.log(` ${scopeUnit} not running (scope absent)`);
198
+ }
199
+ else {
200
+ // Any other non-zero exit means systemctl itself failed (e.g. DBus unavailable)
201
+ console.log(` systemctl stop ${scopeUnit} exited ${stopResult.status} — scope may still be running`);
199
202
  }
200
203
  // Brand isolation: the VNC stack and Ollama daemon are
201
204
  // device-wide singletons. Killing them during uninstall would interrupt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.233",
3
+ "version": "0.1.234",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for resume-tunnel.sh scope-based spawn path (Task 602).
3
+ # Stubs systemd-run, systemctl, and cloudflared binaries via a fake $PATH entry.
4
+ set -uo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ RESUME="$SCRIPT_DIR/../resume-tunnel.sh"
8
+
9
+ PASS=0
10
+ FAIL=0
11
+
12
+ run_case() {
13
+ local name="$1"; shift
14
+ echo ""
15
+ echo "=== CASE: $name ==="
16
+ if "$@"; then
17
+ PASS=$((PASS + 1))
18
+ echo "PASS: $name"
19
+ else
20
+ FAIL=$((FAIL + 1))
21
+ echo "FAIL: $name"
22
+ fi
23
+ }
24
+
25
+ # Build a minimal install tree with stubs and state file.
26
+ # Args: root config_dir scope_active connect_success
27
+ setup() {
28
+ local root="$1"
29
+ local config_dir="${2:-.maxy-code}"
30
+ local scope_active="${3:-inactive}"
31
+ local connect_success="${4:-yes}"
32
+
33
+ local home_dir="$root/home"
34
+ local platform_root="$root/platform_root"
35
+ local bin_dir="$root/bin"
36
+ mkdir -p "$home_dir/$config_dir/cloudflared" \
37
+ "$home_dir/$config_dir/logs" \
38
+ "$platform_root/config" \
39
+ "$bin_dir"
40
+
41
+ cat > "$platform_root/config/brand.json" <<JSON
42
+ {"configDir": "$config_dir"}
43
+ JSON
44
+
45
+ cat > "$home_dir/$config_dir/cloudflared/tunnel.state" <<JSON
46
+ {
47
+ "tunnelId": "test-tunnel-id",
48
+ "domain": "test.example.com",
49
+ "configPath": "$home_dir/$config_dir/cloudflared/config.yml",
50
+ "pid": 99999
51
+ }
52
+ JSON
53
+
54
+ touch "$home_dir/$config_dir/cloudflared/cert.pem"
55
+ touch "$home_dir/$config_dir/cloudflared/config.yml"
56
+
57
+ local log_file="$home_dir/$config_dir/logs/cloudflared.log"
58
+
59
+ # Stub cloudflared: does nothing.
60
+ cat > "$bin_dir/cloudflared" <<'SH'
61
+ #!/bin/bash
62
+ sleep 0.1
63
+ SH
64
+ chmod +x "$bin_dir/cloudflared"
65
+
66
+ # Stub systemctl: is-active returns based on scope_active.
67
+ cat > "$bin_dir/systemctl" <<SH
68
+ #!/bin/bash
69
+ if [[ "\$*" == *"is-active"* ]]; then
70
+ if [[ "${scope_active}" == "active" ]]; then
71
+ echo "active"; exit 0
72
+ else
73
+ echo "inactive"; exit 3
74
+ fi
75
+ fi
76
+ if [[ "\$*" == *"--property=MainPID"* ]]; then
77
+ echo "12345"
78
+ fi
79
+ exit 0
80
+ SH
81
+ chmod +x "$bin_dir/systemctl"
82
+
83
+ # Stub systemd-run: logs its --unit= and --slice= args, optionally writes connection line.
84
+ cat > "$bin_dir/systemd-run" <<SH
85
+ #!/bin/bash
86
+ for arg in "\$@"; do
87
+ case "\$arg" in
88
+ --unit=*) echo "[stub] unit=\${arg#--unit=}" ;;
89
+ --slice=*) echo "[stub] slice=\${arg#--slice=}" ;;
90
+ esac
91
+ done
92
+ if [[ "${connect_success}" == "yes" ]]; then
93
+ echo "2026-06-02T00:00:00Z INF Registered tunnel connection connIndex=0" >> "${log_file}"
94
+ fi
95
+ exit 0
96
+ SH
97
+ chmod +x "$bin_dir/systemd-run"
98
+ }
99
+
100
+ cleanup() { rm -rf "$1"; }
101
+
102
+ # CASE 1: skip branch — scope is active
103
+ case_skip_when_scope_active() {
104
+ local root
105
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
106
+ setup "$root" ".maxy-code" "active" "yes"
107
+
108
+ local log="$root/home/.maxy-code/logs/cloudflared.log"
109
+
110
+ HOME="$root/home" \
111
+ PATH="$root/bin:$PATH" \
112
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
113
+ bash "$RESUME"
114
+ local rc=$?
115
+
116
+ local log_content
117
+ log_content=$(cat "$log" 2>/dev/null || echo "")
118
+ cleanup "$root"
119
+
120
+ if [[ $rc -ne 0 ]]; then
121
+ echo " expected exit 0, got $rc"; return 1
122
+ fi
123
+ if ! echo "$log_content" | grep -q "already running.*scope.*cloudflared-maxy-code"; then
124
+ echo " skip branch missing expected log line"
125
+ echo "$log_content"
126
+ return 1
127
+ fi
128
+ return 0
129
+ }
130
+
131
+ # CASE 2: spawn branch — scope inactive, connection succeeds
132
+ case_spawn_succeeds() {
133
+ local root
134
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
135
+ setup "$root" ".maxy-code" "inactive" "yes"
136
+
137
+ local log="$root/home/.maxy-code/logs/cloudflared.log"
138
+
139
+ HOME="$root/home" \
140
+ PATH="$root/bin:$PATH" \
141
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
142
+ bash "$RESUME"
143
+ local rc=$?
144
+
145
+ local log_content
146
+ log_content=$(cat "$log" 2>/dev/null || echo "")
147
+ cleanup "$root"
148
+
149
+ if [[ $rc -ne 0 ]]; then
150
+ echo " expected exit 0, got $rc"; return 1
151
+ fi
152
+ if ! echo "$log_content" | grep -q "spawned scope=cloudflared-maxy-code.scope"; then
153
+ echo " missing spawned scope log line"; echo "$log_content"; return 1
154
+ fi
155
+ if ! echo "$log_content" | grep -q "tunnel verified"; then
156
+ echo " missing tunnel verified log line"; echo "$log_content"; return 1
157
+ fi
158
+ return 0
159
+ }
160
+
161
+ # CASE 3: spawn branch — no edge connection within timeout
162
+ case_spawn_no_connection() {
163
+ local root
164
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
165
+ setup "$root" ".maxy-code" "inactive" "no"
166
+
167
+ local log="$root/home/.maxy-code/logs/cloudflared.log"
168
+
169
+ HOME="$root/home" \
170
+ PATH="$root/bin:$PATH" \
171
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
172
+ RESUME_TUNNEL_VERIFY_DEADLINE=2 \
173
+ bash "$RESUME"
174
+ local rc=$?
175
+
176
+ local log_content
177
+ log_content=$(cat "$log" 2>/dev/null || echo "")
178
+ cleanup "$root"
179
+
180
+ if [[ $rc -ne 0 ]]; then
181
+ echo " expected exit 0 even on failure, got $rc"; return 1
182
+ fi
183
+ if ! echo "$log_content" | grep -q "ERROR.*no edge connection"; then
184
+ echo " missing ERROR log line on connection failure"; echo "$log_content"; return 1
185
+ fi
186
+ return 0
187
+ }
188
+
189
+ # CASE 4: scope unit name derived correctly from configDir (.real-agent → cloudflared-real-agent.scope)
190
+ case_scope_unit_name_from_config_dir() {
191
+ local root
192
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
193
+ setup "$root" ".real-agent" "inactive" "yes"
194
+
195
+ local log="$root/home/.real-agent/logs/cloudflared.log"
196
+
197
+ HOME="$root/home" \
198
+ PATH="$root/bin:$PATH" \
199
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
200
+ bash "$RESUME"
201
+ local rc=$?
202
+
203
+ local log_content
204
+ log_content=$(cat "$log" 2>/dev/null || echo "")
205
+ cleanup "$root"
206
+
207
+ if [[ $rc -ne 0 ]]; then
208
+ echo " expected exit 0, got $rc"; return 1
209
+ fi
210
+ if ! echo "$log_content" | grep -q "spawned scope=cloudflared-real-agent.scope"; then
211
+ echo " scope unit name not derived correctly from .real-agent"
212
+ echo "$log_content"; return 1
213
+ fi
214
+ return 0
215
+ }
216
+
217
+ # CASE 5: --slice=cloudflared flag passed to systemd-run
218
+ case_slice_flag_present() {
219
+ local root
220
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
221
+ setup "$root" ".maxy-code" "inactive" "yes"
222
+
223
+ local log="$root/home/.maxy-code/logs/cloudflared.log"
224
+
225
+ HOME="$root/home" \
226
+ PATH="$root/bin:$PATH" \
227
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
228
+ bash "$RESUME"
229
+
230
+ local log_content
231
+ log_content=$(cat "$log" 2>/dev/null || echo "")
232
+ cleanup "$root"
233
+
234
+ # The systemd-run stub echoes "[stub] slice=cloudflared" to stdout,
235
+ # which is redirected to LOG_FILE by the script's >> redirect.
236
+ if ! echo "$log_content" | grep -q "\[stub\] slice=cloudflared"; then
237
+ echo " --slice=cloudflared not seen by systemd-run stub"
238
+ echo "$log_content"; return 1
239
+ fi
240
+ return 0
241
+ }
242
+
243
+ run_case "skip when scope active" case_skip_when_scope_active
244
+ run_case "spawn succeeds and logs verified" case_spawn_succeeds
245
+ run_case "spawn with no connection logs ERROR exits 0" case_spawn_no_connection
246
+ run_case "scope unit name derived from configDir" case_scope_unit_name_from_config_dir
247
+ run_case "slice flag passed to systemd-run" case_slice_flag_present
248
+
249
+ echo ""
250
+ echo "Results: $PASS passed, $FAIL failed"
251
+ [[ $FAIL -eq 0 ]]
@@ -1,17 +1,18 @@
1
1
  #!/bin/bash
2
- # resume-tunnel.sh — Resume the Cloudflare tunnel on boot
2
+ # resume-tunnel.sh — Resume the Cloudflare tunnel on brand-service start.
3
3
  #
4
- # Called as ExecStartPre in maxy.service. Reads brand-specific tunnel state
5
- # from ~/{configDir}/cloudflared/tunnel.state, checks whether the recorded
6
- # PID is still a running cloudflared process, and spawns a new daemon if not.
7
- # All failures exit 0 — this script must never prevent maxy.service from starting.
4
+ # Spawns cloudflared into its own transient systemd scope (cloudflared-<brand>.scope)
5
+ # under cloudflared.slice, completely decoupled from the brand service's cgroup.
6
+ # A brand-service restart can no longer reap the tunnel. Task 602.
8
7
  #
9
- # Dependencies: jq, cloudflared (both installed by create-maxy)
8
+ # Called as ExecStartPre in maxy.service. All failures exit 0 — this script must
9
+ # never prevent maxy.service from starting.
10
+ #
11
+ # Dependencies: jq, cloudflared, systemd-run, systemctl
10
12
 
11
13
  set -uo pipefail
12
14
 
13
- # Derive configDir from brand.json (MAXY_PLATFORM_ROOT is set by the systemd service).
14
- # Fall back to .maxy if brand.json is unreadable.
15
+ # Derive configDir from brand.json. Fall back to .maxy if unreadable.
15
16
  CONFIG_DIR=".maxy"
16
17
  if [ -n "${MAXY_PLATFORM_ROOT:-}" ] && [ -f "$MAXY_PLATFORM_ROOT/config/brand.json" ]; then
17
18
  BRAND_CONFIG_DIR=$(jq -r '.configDir // empty' "$MAXY_PLATFORM_ROOT/config/brand.json" 2>/dev/null || true)
@@ -20,9 +21,7 @@ if [ -n "${MAXY_PLATFORM_ROOT:-}" ] && [ -f "$MAXY_PLATFORM_ROOT/config/brand.js
20
21
  fi
21
22
  fi
22
23
 
23
- # State file under ~/{configDir}/cloudflared/ — each brand has its own tunnel state.
24
24
  STATE_FILE="$HOME/$CONFIG_DIR/cloudflared/tunnel.state"
25
-
26
25
  LOG_DIR="$HOME/$CONFIG_DIR/logs"
27
26
  LOG_FILE="$LOG_DIR/cloudflared.log"
28
27
 
@@ -32,13 +31,10 @@ log() {
32
31
  }
33
32
 
34
33
  # No state file — no tunnel configured. Normal boot.
35
- if [ ! -f "$STATE_FILE" ]; then
36
- exit 0
37
- fi
34
+ [ -f "$STATE_FILE" ] || exit 0
38
35
 
39
36
  log "reading state from $STATE_FILE (configDir=$CONFIG_DIR)"
40
37
 
41
- # Parse state file
42
38
  if ! STATE=$(jq -r '.' "$STATE_FILE" 2>/dev/null); then
43
39
  log "WARNING: tunnel.state is not valid JSON — skipping resume"
44
40
  exit 0
@@ -47,64 +43,106 @@ fi
47
43
  TUNNEL_ID=$(echo "$STATE" | jq -r '.tunnelId // empty')
48
44
  DOMAIN=$(echo "$STATE" | jq -r '.domain // empty')
49
45
  CONFIG_PATH=$(echo "$STATE" | jq -r '.configPath // empty')
50
- OLD_PID=$(echo "$STATE" | jq -r '.pid // empty')
51
46
 
52
47
  if [ -z "$TUNNEL_ID" ]; then
53
48
  log "WARNING: tunnel.state has no tunnelId — skipping resume"
54
49
  exit 0
55
50
  fi
56
51
 
57
- # Determine startup mode: config.yml (current) or --token (legacy)
58
- CERT_PATH="$HOME/$CONFIG_DIR/cloudflared/cert.pem"
52
+ # Derive scope unit name: strip leading dot from CONFIG_DIR.
53
+ # .maxy-code → cloudflared-maxy-code.scope
54
+ BRAND="${CONFIG_DIR#.}"
55
+ SCOPE_UNIT="cloudflared-${BRAND}.scope"
59
56
 
60
- # Check if the recorded PID is still a running cloudflared process
61
- if [ -n "$OLD_PID" ] && [ "$OLD_PID" != "null" ]; then
62
- if kill -0 "$OLD_PID" 2>/dev/null; then
63
- # PID is alive verify it's actually cloudflared (not a reused PID)
64
- PROC_NAME=$(cat "/proc/$OLD_PID/comm" 2>/dev/null || true)
65
- if [ "$PROC_NAME" = "cloudflared" ]; then
66
- log "Tunnel already running (PID $OLD_PID) — skipping resume"
67
- exit 0
68
- fi
69
- # PID alive but not cloudflared — stale PID from before reboot
70
- log "PID $OLD_PID is alive but is '$PROC_NAME', not cloudflared — will start new daemon"
71
- fi
57
+ # Liveness check: query the scope unit state, not a recorded PID.
58
+ # The scope is owned by user-systemd, fully outside the brand service's cgroup.
59
+ if systemctl --user is-active "$SCOPE_UNIT" >/dev/null 2>&1; then
60
+ SCOPE_PID=$(systemctl --user show "$SCOPE_UNIT" --property=MainPID --value 2>/dev/null || echo "unknown")
61
+ log "Tunnel already running (scope $SCOPE_UNIT active, MainPID $SCOPE_PID) — skipping resume"
62
+ exit 0
72
63
  fi
73
64
 
74
- # Find cloudflared binary
65
+ CERT_PATH="$HOME/$CONFIG_DIR/cloudflared/cert.pem"
75
66
  CLOUDFLARED_BIN=$(command -v cloudflared 2>/dev/null || true)
67
+
76
68
  if [ -z "$CLOUDFLARED_BIN" ]; then
77
69
  log "ERROR: cloudflared binary not found — cannot resume tunnel"
78
70
  exit 0
79
71
  fi
80
72
 
81
- # Start the tunnel daemon (detached, survives service restart)
82
- log "Resuming tunnel $TUNNEL_ID for $DOMAIN..."
83
- mkdir -p "$LOG_DIR"
84
-
85
- if [ -n "$CONFIG_PATH" ] && [ -f "$CONFIG_PATH" ] && [ -f "$CERT_PATH" ]; then
86
- log "Starting with config: $CONFIG_PATH, cert: $CERT_PATH"
87
- nohup "$CLOUDFLARED_BIN" --origincert "$CERT_PATH" --config "$CONFIG_PATH" tunnel run >> "$LOG_FILE" 2>&1 &
88
- else
73
+ if [ -z "$CONFIG_PATH" ] || [ ! -f "$CONFIG_PATH" ] || [ ! -f "$CERT_PATH" ]; then
89
74
  log "ERROR: missing configPath or cert — cannot resume tunnel"
90
75
  exit 0
91
76
  fi
92
- NEW_PID=$!
93
77
 
94
- if [ -z "$NEW_PID" ] || [ "$NEW_PID" -le 0 ] 2>/dev/null; then
95
- log "ERROR: failed to spawn cloudflared — no PID returned"
96
- exit 0
78
+ log "Resuming tunnel $TUNNEL_ID for $DOMAIN..."
79
+
80
+ # Capture log byte offset before spawning so the verification poll only
81
+ # matches lines written by THIS invocation, not lines from a prior run.
82
+ mkdir -p "$LOG_DIR"
83
+ LOG_OFFSET=$(wc -c < "$LOG_FILE" 2>/dev/null | awk '{print $1+0}' || echo 0)
84
+
85
+ # Spawn into a transient scope under cloudflared.slice.
86
+ # The scope is owned by user-systemd, not by the brand service's cgroup.
87
+ # systemd-run --scope blocks until cloudflared exits (it is the scope leader),
88
+ # so we background it. $? after & is always 0 (fork success); the verification
89
+ # gate below detects actual spawn failures via connection timeout.
90
+ # stdout/stderr of cloudflared append to LOG_FILE via the redirect.
91
+ systemd-run \
92
+ --user \
93
+ --scope \
94
+ --quiet \
95
+ --unit="$SCOPE_UNIT" \
96
+ --slice=cloudflared \
97
+ -p TimeoutStopSec=15 \
98
+ -- \
99
+ "$CLOUDFLARED_BIN" --origincert "$CERT_PATH" --config "$CONFIG_PATH" tunnel run \
100
+ >> "$LOG_FILE" 2>&1 &
101
+
102
+ # Log spawn receipt so the operator can distinguish the scoped path from legacy nohup.
103
+ SCOPE_PID=$(systemctl --user show "$SCOPE_UNIT" --property=MainPID --value 2>/dev/null || echo "unknown")
104
+ log "spawned scope=$SCOPE_UNIT slice=cloudflared main-pid=$SCOPE_PID"
105
+
106
+ # Post-spawn connection verification gate.
107
+ # Polls only new log content (from LOG_OFFSET onwards) to avoid false positives
108
+ # from "Registered tunnel connection" lines written by a prior run.
109
+ # Overridable via RESUME_TUNNEL_VERIFY_DEADLINE for testing.
110
+ # On failure: log explicit ERROR and exit 0 — must not block brand-service start.
111
+ VERIFY_DEADLINE="${RESUME_TUNNEL_VERIFY_DEADLINE:-20}"
112
+ VERIFY_START=$(date +%s)
113
+ VERIFIED=0
114
+ while true; do
115
+ if tail -c "+$((LOG_OFFSET + 1))" "$LOG_FILE" 2>/dev/null | grep -q "Registered tunnel connection"; then
116
+ VERIFIED=1
117
+ break
118
+ fi
119
+ NOW=$(date +%s)
120
+ ELAPSED=$(( NOW - VERIFY_START ))
121
+ if [ "$ELAPSED" -ge "$VERIFY_DEADLINE" ]; then
122
+ break
123
+ fi
124
+ sleep 1
125
+ done
126
+
127
+ if [ "$VERIFIED" -eq 1 ]; then
128
+ ELAPSED=$(( $(date +%s) - VERIFY_START ))
129
+ CONN_COUNT=$(tail -c "+$((LOG_OFFSET + 1))" "$LOG_FILE" 2>/dev/null | grep -c "Registered tunnel connection" || echo "?")
130
+ log "tunnel verified scope=$SCOPE_UNIT connections=$CONN_COUNT ms=$(( ELAPSED * 1000 ))"
131
+ else
132
+ log "ERROR: tunnel scope started but no edge connection within ${VERIFY_DEADLINE}s — scope=$SCOPE_UNIT"
97
133
  fi
98
134
 
99
- # Update state file with new PID
100
- UPDATED_STATE=$(echo "$STATE" | jq --argjson pid "$NEW_PID" --argjson ts "$(date +%s)000" \
101
- '.pid = $pid | .startedAt = $ts')
135
+ # Update state file: keep pid/startedAt for observability, add scopeUnit as control signal.
136
+ NEW_STATE=$(echo "$STATE" | jq \
137
+ --arg unit "$SCOPE_UNIT" \
138
+ --argjson ts "$(date +%s)000" \
139
+ '.scopeUnit = $unit | .startedAt = $ts' 2>/dev/null || echo "")
102
140
 
103
- if echo "$UPDATED_STATE" > "$STATE_FILE.tmp" 2>/dev/null && mv "$STATE_FILE.tmp" "$STATE_FILE" 2>/dev/null; then
104
- log "Tunnel resumed — PID $NEW_PID"
141
+ if [ -n "$NEW_STATE" ] && echo "$NEW_STATE" > "$STATE_FILE.tmp" 2>/dev/null && mv "$STATE_FILE.tmp" "$STATE_FILE" 2>/dev/null; then
142
+ :
105
143
  else
106
144
  rm -f "$STATE_FILE.tmp" 2>/dev/null
107
- log "WARNING: tunnel started (PID $NEW_PID) but failed to update state file"
145
+ log "WARNING: tunnel started but failed to update state file"
108
146
  fi
109
147
 
110
148
  exit 0