Skip to content

Commit 305b3eb

Browse files
committed
fix(uninstall): bind external gateway process
1 parent a1e3a96 commit 305b3eb

5 files changed

Lines changed: 188 additions & 6 deletions

File tree

docs/manage-sandboxes/uninstall-nemoclaw.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ A single full uninstall reports the process and continues.
129129
Before each sandbox deletion during scoped Docker cleanup, NemoClaw proves the selected configuration and running gateway identity again and passes the selected gateway name to OpenShell.
130130
The configuration and running process must use the state-root-specific OpenShell sandbox namespace that NemoClaw generated.
131131
For a NemoClaw-managed gateway, the live proof also binds the process owner, PID file, runtime marker, and command line to the exact gateway name and port.
132-
For an externally supervised gateway, NemoClaw proves the configured state, the supervisor's current main process, its owner, and its loaded sandbox namespace.
132+
For an externally supervised gateway, NemoClaw proves the configured state.
133+
It binds the supervisor's current main process to its owner, loaded sandbox namespace, declared executable, selected gateway name, and selected port.
133134
When NemoClaw can prove an owner-private, generated configuration and complete JWT bundle that predate state-root scoping, restart keeps the legacy gateway ID, JWT bundle, and Docker driver's `default` namespace.
134135
That compatibility keeps the gateway able to find existing containers and keeps their non-expiring sandbox JWT issuer valid.
135136
NemoClaw regenerates the other gateway settings from the current runtime configuration.

docs/reference/commands.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4305,7 +4305,8 @@ A single full uninstall reports the process and continues.
43054305
Before each sandbox deletion during scoped Docker cleanup, NemoClaw proves the selected configuration and running gateway identity again and passes the selected gateway name to OpenShell.
43064306
The configuration and running process must use the state-root-specific OpenShell sandbox namespace that NemoClaw generated.
43074307
For a NemoClaw-managed gateway, the live proof also binds the process owner, PID file, runtime marker, and command line to the exact gateway name and port.
4308-
For an externally supervised gateway, NemoClaw proves the configured state, the supervisor's current main process, its owner, and its loaded sandbox namespace.
4308+
For an externally supervised gateway, NemoClaw proves the configured state.
4309+
It binds the supervisor's current main process to its owner, loaded sandbox namespace, declared executable, selected gateway name, and selected port.
43094310
When NemoClaw can prove an owner-private, generated configuration and complete JWT bundle that predate state-root scoping, restart keeps the legacy gateway ID, JWT bundle, and Docker driver's `default` namespace.
43104311
That compatibility keeps the gateway able to find existing containers and keeps their non-expiring sandbox JWT issuer valid.
43114312
NemoClaw regenerates the other gateway settings from the current runtime configuration.

src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ describe("uninstall gateway-port segregation (#3053)", () => {
155155
readProcessEnvironment: () => ({
156156
NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE: gatewayIdForStateDir(externalStateDir),
157157
}),
158+
readProcessExecutable: () => "/usr/local/bin/openshell-gateway",
158159
rmSync: fs.rmSync,
159160
run: (command, args) => {
160161
calls.push({ args, command });
@@ -165,6 +166,9 @@ describe("uninstall gateway-port segregation (#3053)", () => {
165166
(command === "ps" &&
166167
args.includes("uid=") &&
167168
ok(`${String(process.getuid?.() ?? -1)}\n`)) ||
169+
(command === "ps" &&
170+
args.includes("args=") &&
171+
ok("/usr/local/bin/openshell-gateway --name nemoclaw --port 8080\n")) ||
168172
ok()
169173
);
170174
},
@@ -203,6 +207,105 @@ describe("uninstall gateway-port segregation (#3053)", () => {
203207
},
204208
);
205209

210+
it.each([
211+
{
212+
title: "the executable differs from the declared supervisor executable",
213+
executable: "/usr/bin/openshell-gateway",
214+
commandLine: "/usr/local/bin/openshell-gateway --name nemoclaw --port 8080",
215+
},
216+
{
217+
title: "the command line names another gateway",
218+
executable: "/usr/local/bin/openshell-gateway",
219+
commandLine: "/usr/local/bin/openshell-gateway --name nemoclaw-8091 --port 8080",
220+
},
221+
{
222+
title: "the command line names another port",
223+
executable: "/usr/local/bin/openshell-gateway",
224+
commandLine: "/usr/local/bin/openshell-gateway --name nemoclaw --port 8091",
225+
},
226+
])("refuses scoped cleanup when $title", ({ executable, commandLine }) => {
227+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-external-proof-"));
228+
try {
229+
const shared = path.join(tmpHome, ".nemoclaw");
230+
const gatewayStatePath = writeScopedGatewayState(tmpHome);
231+
const externalStateDir = path.dirname(gatewayStatePath);
232+
fs.mkdirSync(shared, { recursive: true });
233+
fs.writeFileSync(
234+
path.join(shared, "sandboxes.json"),
235+
JSON.stringify({
236+
defaultSandbox: "alpha",
237+
sandboxes: {
238+
alpha: { name: "alpha", gatewayName: "nemoclaw", gatewayPort: 8080 },
239+
beta: { name: "beta", gatewayName: "nemoclaw-8091", gatewayPort: 8091 },
240+
},
241+
}),
242+
);
243+
const calls: Array<{ args: string[]; command: string }> = [];
244+
const warnings: string[] = [];
245+
const externalPid = 4242;
246+
247+
const result = runUninstallPlan(
248+
{ assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true },
249+
{
250+
commandExists: (command) => command === "openshell",
251+
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
252+
existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target),
253+
isTty: false,
254+
log: vi.fn(),
255+
readProcessEnvironment: () => ({
256+
NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE: gatewayIdForStateDir(externalStateDir),
257+
}),
258+
readProcessExecutable: () => executable,
259+
resolveGatewayTeardownAuthority: ({ gatewayName, gatewayPort }) => ({
260+
gatewayName,
261+
gatewayPort,
262+
mode: "externally-supervised",
263+
source: "declared",
264+
endpoint: `http://127.0.0.1:${String(gatewayPort)}`,
265+
stateDir: externalStateDir,
266+
supervisor: {
267+
kind: "systemd-system",
268+
serviceName: "openshell-gateway.service",
269+
execPath: "/usr/local/bin/openshell-gateway",
270+
},
271+
requiredCapabilities: [],
272+
}),
273+
rmSync: fs.rmSync,
274+
run: (command, args) => {
275+
calls.push({ args, command });
276+
if (command === "openshell" && args[0] === "gateway" && args[1] === "list") {
277+
return ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-8091" }]));
278+
}
279+
if (command === "systemctl" && args.includes("--property=MainPID")) {
280+
return ok(`${String(externalPid)}\n`);
281+
}
282+
if (command === "ps" && args.includes("uid=")) {
283+
return ok(`${String(process.getuid?.() ?? -1)}\n`);
284+
}
285+
if (command === "ps" && args.includes("args=")) return ok(`${commandLine}\n`);
286+
return ok();
287+
},
288+
runDocker: () => ok(),
289+
error: (message) => warnings.push(message),
290+
},
291+
);
292+
293+
expect(result.exitCode).toBe(1);
294+
expect(
295+
calls.some(
296+
({ command, args }) =>
297+
command === "openshell" && args[0] === "sandbox" && args[1] === "delete",
298+
),
299+
).toBe(false);
300+
expect(warnings.join("\n")).toContain(
301+
"Refusing scoped gateway cleanup because the externally supervised process identity cannot be proven",
302+
);
303+
expect(fs.existsSync(gatewayStatePath)).toBe(true);
304+
} finally {
305+
fs.rmSync(tmpHome, { recursive: true, force: true });
306+
}
307+
});
308+
206309
it("does not use legacy gateway destroy when external registration removal is unsupported (#6576)", () => {
207310
const calls: Array<{ args: string[]; command: string }> = [];
208311
const responses = new Map<string, RunResult>([

src/lib/actions/uninstall/run-plan.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ import {
5959
resolveGatewayTeardownAuthority,
6060
} from "../../onboard/gateway-teardown-authority";
6161
import {
62+
externallySupervisedHostGatewayProcessOwnershipFailure,
6263
hasStateScopedSandboxNamespace,
63-
processUsesStateScopedSandboxNamespace,
6464
scopedHostGatewayProcessOwnershipFailure,
6565
type StopHostGatewayOptions,
6666
stopHostGatewayProcesses,
@@ -109,6 +109,7 @@ export interface UninstallRunDeps {
109109
openRegularFile?: typeof openRegularFileNoFollow;
110110
platform?: NodeJS.Platform;
111111
readProcessArgv?: (pid: number) => readonly string[] | null;
112+
readProcessExecutable?: (pid: number) => string | null;
112113
readProcessEnvironment?: (pid: number) => Record<string, string> | null;
113114
readLine?: () => string | null;
114115
requireCompleteGatewayProcessCleanup?: boolean;
@@ -446,6 +447,7 @@ interface UninstallRuntime {
446447
openRegularFile: typeof openRegularFileNoFollow;
447448
platform: NodeJS.Platform;
448449
readProcessArgv: ((pid: number) => readonly string[] | null) | undefined;
450+
readProcessExecutable: ((pid: number) => string | null) | undefined;
449451
readProcessEnvironment: ((pid: number) => Record<string, string> | null) | undefined;
450452
readLine: () => string | null;
451453
requireCompleteGatewayProcessCleanup: boolean;
@@ -488,6 +490,7 @@ function buildRuntime(deps: UninstallRunDeps): UninstallRuntime {
488490
openRegularFile: deps.openRegularFile ?? openRegularFileNoFollow,
489491
platform: deps.platform ?? process.platform,
490492
readProcessArgv: deps.readProcessArgv,
493+
readProcessExecutable: deps.readProcessExecutable,
491494
readProcessEnvironment: deps.readProcessEnvironment,
492495
readLine: deps.readLine ?? readLineFromStdin,
493496
requireCompleteGatewayProcessCleanup: deps.requireCompleteGatewayProcessCleanup ?? false,
@@ -1436,10 +1439,28 @@ function canRemoveScopedOpenShellResources(
14361439
if (
14371440
inspected.status === 0 &&
14381441
Number.isSafeInteger(mainPid) &&
1439-
mainPid > 0 &&
1440-
processUsesStateScopedSandboxNamespace(mainPid, stateDir, runtime)
1442+
mainPid > 0
14411443
) {
1442-
return true;
1444+
const reason = externallySupervisedHostGatewayProcessOwnershipFailure(
1445+
{
1446+
env: runtime.env,
1447+
readProcessEnvironment: runtime.readProcessEnvironment,
1448+
readProcessExecutable: runtime.readProcessExecutable,
1449+
run: runtime.run,
1450+
},
1451+
{
1452+
gatewayBin: supervisor.execPath,
1453+
gatewayName: teardownAuthority.gatewayName,
1454+
gatewayPort: teardownAuthority.gatewayPort,
1455+
pid: mainPid,
1456+
stateDir,
1457+
},
1458+
);
1459+
if (reason === null) return true;
1460+
runtime.warn(
1461+
`Refusing scoped gateway cleanup because the externally supervised process identity cannot be proven: ${reason}.`,
1462+
);
1463+
return false;
14431464
}
14441465
}
14451466
runtime.warn(

src/lib/onboard/host-gateway-process.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface HostGatewayProcessDeps {
3838
commandExists?: (command: string) => boolean;
3939
isPortFree?: (port: number) => boolean;
4040
log?: (message: string) => void;
41+
readProcessExecutable?: (pid: number) => string | null;
4142
readProcessEnvironment?: (pid: number) => Record<string, string> | null;
4243
warn?: (message: string) => void;
4344
}
@@ -145,6 +146,7 @@ function defaultDeps(overrides: Partial<HostGatewayProcessDeps> = {}): HostGatew
145146
commandExists: overrides.commandExists ?? ((cmd) => defaultCommandExists(cmd, env)),
146147
isPortFree: overrides.isPortFree ?? ((port) => isHostPortFree(port)),
147148
log: overrides.log,
149+
readProcessExecutable: overrides.readProcessExecutable,
148150
readProcessEnvironment: overrides.readProcessEnvironment,
149151
warn: overrides.warn,
150152
};
@@ -281,6 +283,60 @@ export function processUsesStateScopedSandboxNamespace(
281283
return environment?.[NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV] === gatewayIdForStateDir(stateDir);
282284
}
283285

286+
function readProcessExecutable(pid: number, deps: HostGatewayProcessDeps): string | null {
287+
if (deps.readProcessExecutable) return deps.readProcessExecutable(pid);
288+
try {
289+
return fs.realpathSync.native(`/proc/${String(pid)}/exe`);
290+
} catch {
291+
return null;
292+
}
293+
}
294+
295+
function normalizeProcessExecutable(value: string): string {
296+
try {
297+
return fs.realpathSync.native(value);
298+
} catch {
299+
return path.resolve(value);
300+
}
301+
}
302+
303+
export function externallySupervisedHostGatewayProcessOwnershipFailure(
304+
depsOverrides: Partial<HostGatewayProcessDeps>,
305+
options: {
306+
gatewayBin: string;
307+
gatewayName: string;
308+
gatewayPort: number;
309+
pid: number;
310+
stateDir: string;
311+
},
312+
): string | null {
313+
const deps = defaultDeps(depsOverrides);
314+
if (!canonicalGatewayTargetMatches(options.gatewayName, options.gatewayPort)) {
315+
return "selected gateway name and port are not canonical";
316+
}
317+
if (!processUsesStateScopedSandboxNamespace(options.pid, options.stateDir, deps)) {
318+
return "gateway process owner and loaded sandbox namespace cannot be proven";
319+
}
320+
const executable = readProcessExecutable(options.pid, deps);
321+
if (
322+
!executable ||
323+
normalizeProcessExecutable(executable) !== normalizeProcessExecutable(options.gatewayBin)
324+
) {
325+
return "process executable does not match the declared supervisor executable";
326+
}
327+
if (
328+
!hostGatewayCmdlineMatches(
329+
processArgs(options.pid, deps),
330+
options.gatewayBin,
331+
{ name: options.gatewayName, port: options.gatewayPort },
332+
{ requireExpectedFlags: true },
333+
)
334+
) {
335+
return "process command line does not identify the selected gateway name and port";
336+
}
337+
return null;
338+
}
339+
284340
export function hostGatewayCmdlineMatches(
285341
cmdline: string,
286342
gatewayBin: string | null | undefined,

0 commit comments

Comments
 (0)