|
3 | 3 |
|
4 | 4 | import { describe, it, expect } from "vitest"; |
5 | 5 | // Import from compiled dist/ so coverage is attributed correctly. |
6 | | -import { buildRecoveryScript } from "../../dist/lib/agent-runtime"; |
| 6 | +import { buildOpenClawRecoveryScript, buildRecoveryScript } from "../../dist/lib/agent-runtime"; |
7 | 7 | import type { AgentDefinition } from "./agent-defs"; |
8 | 8 |
|
9 | 9 | function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition { |
@@ -60,13 +60,13 @@ describe("buildRecoveryScript", () => { |
60 | 60 | it("launches the default gateway command through the validated agent binary", () => { |
61 | 61 | const script = buildRecoveryScript(minimalAgent, 19000); |
62 | 62 | expect(script).toContain("command -v 'test-agent'"); |
63 | | - expect(script).toContain('nohup "$AGENT_BIN" gateway run --port 19000'); |
| 63 | + expect(script).toContain('"$AGENT_BIN" gateway run --port 19000'); |
64 | 64 | }); |
65 | 65 |
|
66 | 66 | it("falls back to openclaw gateway run when gateway_command is absent", () => { |
67 | 67 | const agent = makeAgent({ gateway_command: undefined }); |
68 | 68 | const script = buildRecoveryScript(agent, 19000); |
69 | | - expect(script).toContain('nohup "$AGENT_BIN" gateway run --port 19000'); |
| 69 | + expect(script).toContain('"$AGENT_BIN" gateway run --port 19000'); |
70 | 70 | }); |
71 | 71 |
|
72 | 72 | it("validates and launches custom gateway commands explicitly", () => { |
@@ -123,26 +123,101 @@ describe("buildRecoveryScript", () => { |
123 | 123 |
|
124 | 124 | it("writes the warning to gateway.log so it persists for sysadmin tail", () => { |
125 | 125 | const script = buildRecoveryScript(minimalAgent, 19000); |
126 | | - // Both warnings must end up in /tmp/gateway.log, not just stderr — |
| 126 | + // Both warnings must end up in the selected gateway log, not just stderr — |
127 | 127 | // executeSandboxCommand silently discards stderr from the recovery |
128 | 128 | // script, so a warning that only goes to stderr is invisible to |
129 | 129 | // anyone debugging a crash-loop. (#2478) |
130 | | - expect(script).toContain('echo "$_W" >> /tmp/gateway.log'); |
| 130 | + expect(script).toContain('echo "$_W" >> "$_GATEWAY_LOG"'); |
131 | 131 | // And the warning must be deferred until AFTER gateway.log is |
132 | | - // freshly touched/chmod'd, otherwise the redirect targets a stale |
133 | | - // file that gets removed seconds later. |
134 | | - const touchIdx = script!.indexOf("touch /tmp/gateway.log"); |
135 | | - const warnIdx = script!.indexOf('echo "$_W" >> /tmp/gateway.log'); |
136 | | - expect(touchIdx).toBeLessThan(warnIdx); |
| 132 | + // safely opened with O_NOFOLLOW, otherwise the redirect targets a |
| 133 | + // stale or attacker-controlled file. |
| 134 | + const gatewayPrepIdx = script!.indexOf(" /tmp/gateway.log || exit 1;"); |
| 135 | + const logSelectionIdx = script!.indexOf("_GATEWAY_LOG=/tmp/gateway.log"); |
| 136 | + const warnIdx = script!.indexOf('echo "$_W" >> "$_GATEWAY_LOG"'); |
| 137 | + expect(gatewayPrepIdx).toBeGreaterThanOrEqual(0); |
| 138 | + expect(logSelectionIdx).toBeGreaterThanOrEqual(0); |
| 139 | + expect(warnIdx).toBeGreaterThanOrEqual(0); |
| 140 | + expect(gatewayPrepIdx).toBeLessThan(logSelectionIdx); |
| 141 | + expect(logSelectionIdx).toBeLessThan(warnIdx); |
| 142 | + }); |
| 143 | + |
| 144 | + it("stops recovery when hardened log setup fails", () => { |
| 145 | + const script = buildOpenClawRecoveryScript(18789); |
| 146 | + expect(script).toContain(" /tmp/gateway.log 'gateway' || exit 1;"); |
| 147 | + expect(script).toContain(" /tmp/auto-pair.log 'sandbox' || exit 1;"); |
137 | 148 | }); |
138 | 149 |
|
139 | 150 | it("appends (not truncates) gateway.log on launch so warnings survive", () => { |
140 | 151 | const script = buildRecoveryScript(minimalAgent, 19000); |
141 | 152 | // Truncating with `>` wipes the [gateway-recovery] WARNING that the |
142 | 153 | // recovery script wrote moments earlier — meaning a sysadmin tailing |
143 | 154 | // gateway.log would see the eventual crash without the explanation. |
144 | | - expect(script).toContain(">> /tmp/gateway.log 2>&1 &"); |
| 155 | + expect(script).toContain('>> "$_GATEWAY_LOG" 2>&1 &'); |
145 | 156 | expect(script).not.toMatch(/[^>]> \/tmp\/gateway\.log 2>&1 &/); |
146 | 157 | }); |
| 158 | + |
| 159 | + it("preserves an existing gateway.log and has a writable fallback log", () => { |
| 160 | + const script = buildOpenClawRecoveryScript(18789); |
| 161 | + expect(script).not.toContain("rm -f /tmp/gateway.log"); |
| 162 | + expect(script).toContain("_GATEWAY_LOG=/tmp/gateway.log"); |
| 163 | + expect(script).toContain("_GATEWAY_LOG=/tmp/gateway-recovery.log"); |
| 164 | + expect(script).toContain('echo "$_W" >> "$_GATEWAY_LOG"'); |
| 165 | + expect(script).toContain('tail -5 "$_GATEWAY_LOG"'); |
| 166 | + expect(script).not.toContain('echo "$_W" >> /tmp/gateway.log'); |
| 167 | + expect(script).not.toContain("cat /tmp/gateway.log"); |
| 168 | + }); |
| 169 | + |
| 170 | + it("rejects a symlinked gateway.log before preparing the log", () => { |
| 171 | + const script = buildOpenClawRecoveryScript(18789); |
| 172 | + const noFollowIdx = script.indexOf("O_NOFOLLOW"); |
| 173 | + const openIdx = script.indexOf("os.open(path, flags, 0o644)"); |
| 174 | + const fchownIdx = script.indexOf("os.fchown(fd"); |
| 175 | + expect(script).toContain("refusing to prepare symlinked /tmp/gateway.log"); |
| 176 | + expect(script).toContain("sys.exit(1)"); |
| 177 | + expect(script).not.toContain(": > /tmp/gateway.log"); |
| 178 | + expect(script).not.toContain("chown 'gateway:gateway' /tmp/gateway.log"); |
| 179 | + expect(noFollowIdx).toBeGreaterThanOrEqual(0); |
| 180 | + expect(openIdx).toBeGreaterThanOrEqual(0); |
| 181 | + expect(fchownIdx).toBeGreaterThanOrEqual(0); |
| 182 | + expect(noFollowIdx).toBeLessThan(openIdx); |
| 183 | + expect(openIdx).toBeLessThan(fchownIdx); |
| 184 | + }); |
| 185 | + |
| 186 | + it("prepares gateway.log for the real gateway-owned sandbox log", () => { |
| 187 | + const script = buildOpenClawRecoveryScript(18789); |
| 188 | + expect(script).toContain("os.fchown(fd"); |
| 189 | + expect(script).toContain("pw.pw_gid"); |
| 190 | + expect(script).not.toContain("grp.getgrnam"); |
| 191 | + expect(script).toContain("owner_mode = 0o644"); |
| 192 | + expect(script).toContain("os.fchmod(fd, owner_mode)"); |
| 193 | + expect(script).toContain("/tmp/gateway.log 'gateway'"); |
| 194 | + expect(script).toContain("gosu 'gateway'"); |
| 195 | + }); |
| 196 | + |
| 197 | + it("terminates the conditional launch branch before capturing the gateway pid", () => { |
| 198 | + const script = buildOpenClawRecoveryScript(18789); |
| 199 | + expect(script).toContain(" fi; GPID=$!"); |
| 200 | + expect(script).not.toContain(" fi GPID=$!"); |
| 201 | + }); |
| 202 | + |
| 203 | + it("prepares auto-pair.log without unlinking or following symlinks", () => { |
| 204 | + const script = buildOpenClawRecoveryScript(18789); |
| 205 | + expect(script).toContain("refusing to prepare symlinked /tmp/auto-pair.log"); |
| 206 | + expect(script).toContain("/tmp/auto-pair.log 'sandbox'"); |
| 207 | + expect(script).toContain("owner_mode = 0o600"); |
| 208 | + expect(script).not.toContain("rm -f /tmp/auto-pair.log"); |
| 209 | + expect(script).not.toContain(": > /tmp/auto-pair.log"); |
| 210 | + expect(script).not.toContain("touch /tmp/auto-pair.log"); |
| 211 | + expect(script).not.toContain("chown sandbox:sandbox /tmp/auto-pair.log"); |
| 212 | + expect(script).not.toContain("chmod 600 /tmp/auto-pair.log"); |
| 213 | + }); |
| 214 | + |
| 215 | + it("does not force non-OpenClaw agents to run as the gateway user", () => { |
| 216 | + const script = buildRecoveryScript(minimalAgent, 19000); |
| 217 | + expect(script).not.toContain("chown gateway:gateway /tmp/gateway.log"); |
| 218 | + expect(script).not.toContain("chown 'gateway:gateway' /tmp/gateway.log"); |
| 219 | + expect(script).not.toContain("gosu gateway"); |
| 220 | + expect(script).not.toContain("gosu 'gateway'"); |
| 221 | + }); |
147 | 222 | }); |
148 | 223 | }); |
0 commit comments