Skip to content

Commit ecadc1f

Browse files
authored
[codex] Enable sudo fallback for Docker panel (#1466)
* Enable sudo fallback for Docker panel * Prefer sudo for Docker panel commands * Use pending saved sudo password immediately * Try plain Docker before sudo fallback * Detect Docker before sudo fallback * Add sudo fallback for Docker popup commands * Harden Docker popup sudo fallback
1 parent 79ccf47 commit ecadc1f

16 files changed

Lines changed: 466 additions & 45 deletions

components/terminal/runtime/createTerminalSessionStarters.test.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,47 @@ test("startSSH forwards custom ProxyCommand to the SSH bridge", async () => {
161161
});
162162
});
163163

164+
test("startSSH forwards the saved sudo autofill password to the SSH bridge", async () => {
165+
let capturedOptions: Record<string, unknown> | null = null;
166+
const terminalBackend = {
167+
backendAvailable: () => true,
168+
telnetAvailable: () => true,
169+
moshAvailable: () => true,
170+
localAvailable: () => true,
171+
serialAvailable: () => true,
172+
execAvailable: () => true,
173+
startSSHSession: async (options: Record<string, unknown>) => {
174+
capturedOptions = options;
175+
return "ssh-session";
176+
},
177+
startTelnetSession: async () => "telnet-session",
178+
startMoshSession: async () => "mosh-session",
179+
startLocalSession: async () => "local-session",
180+
startSerialSession: async () => "serial-session",
181+
execCommand: async () => ({}),
182+
onSessionData: () => noop,
183+
onSessionExit: () => noop,
184+
onChainProgress: () => noop,
185+
writeToSession: noop,
186+
resizeSession: noop,
187+
};
188+
const ctx = createStarterContext({
189+
host: {
190+
id: "host-1",
191+
label: "Target",
192+
hostname: "target.example.test",
193+
username: "alice",
194+
password: "login-secret",
195+
},
196+
terminalBackend,
197+
sudoAutofillPassword: "sudo-secret",
198+
});
199+
200+
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
201+
202+
assert.equal(capturedOptions?.sudoAutofillPassword, "sudo-secret");
203+
});
204+
164205
test("startSSH enables sudo autofill only with the host saved password", async () => {
165206
let onData: ((data: string) => void) | null = null;
166207
const sent: string[] = [];
@@ -255,7 +296,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
255296
assert.deepEqual(sent, []);
256297
});
257298

258-
test("startSSH prefers latest sudo autofill password state over pending saved auth", async () => {
299+
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
259300
let onData: ((data: string) => void) | null = null;
260301
const sent: string[] = [];
261302
const terminalBackend = {
@@ -296,14 +337,15 @@ test("startSSH prefers latest sudo autofill password state over pending saved au
296337
},
297338
},
298339
terminalBackend,
299-
sudoAutofillPasswordRef: { current: undefined },
340+
sudoAutofillPasswordRef: { current: "stale-secret" },
300341
});
301342

302343
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
303344
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
304345
onData?.("[sudo] password for alice: ");
346+
ctx.sudoAutofillRef.current?.confirmFill();
305347

306-
assert.deepEqual(sent, []);
348+
assert.deepEqual(sent, ["pending-secret\n"]);
307349
});
308350

309351
test("startSSH does not use merged group default passwords for sudo autofill", async () => {

components/terminal/runtime/createTerminalSessionStarters.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
5353
};
5454

5555
const resolveSavedSudoAutofillPassword = (): string | undefined => {
56-
if (ctx.sudoAutofillPasswordRef) {
57-
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
58-
}
5956
const pendingAuth = ctx.pendingAuthRef.current;
6057
if (pendingAuth?.savedToHost && pendingAuth.password) {
6158
return sanitizeCredentialValue(pendingAuth.password);
6259
}
60+
if (ctx.sudoAutofillPasswordRef) {
61+
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
62+
}
6363
return sanitizeCredentialValue(ctx.sudoAutofillPassword);
6464
};
6565

@@ -401,6 +401,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
401401
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
402402
identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths,
403403
knownHosts: ctx.knownHosts,
404+
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
404405
// Ask the bridge to reuse the source tab's authenticated connection
405406
// (issue #1204). Only honored on the very first connect attempt; the
406407
// bridge silently falls back to a fresh connection if the source is
@@ -764,6 +765,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
764765
// Lets the stats companion verify the host key before sending a saved
765766
// password (#1198), so it never discloses it to an unvetted host.
766767
knownHosts: ctx.knownHosts,
768+
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
767769
cols: term.cols,
768770
rows: term.rows,
769771
charset: ctx.host.charset,
@@ -1002,6 +1004,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
10021004
knownHosts: ctx.knownHosts,
10031005
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
10041006
agentForwarding: ctx.host.agentForwarding,
1007+
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
10051008
cols: term.cols,
10061009
rows: term.rows,
10071010
charset: ctx.host.charset,

domain/remoteHistory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
99
}
1010

1111
const NETCATTY_MANAGED_STARTUP_COMMAND =
12-
/^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/;
12+
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
1313

1414
/** True when a shell history line came from a Netcatty-managed terminal launch. */
1515
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './dockerShell.ts';
5+
6+
test('buildDockerExecShellCommand probes plain Docker before sudo fallback', () => {
7+
const command = buildDockerExecShellCommand('587abcdef123');
8+
9+
assert.match(command, /^sh -c /);
10+
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
11+
assert.match(command, /docker inspect 587abcdef123/);
12+
assert.match(command, /exec docker exec -it 587abcdef123/);
13+
assert.match(command, /exec sudo docker exec -it 587abcdef123/);
14+
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
15+
assert.doesNotMatch(command, /sudo -S/);
16+
assert.equal(command.includes('\n'), false);
17+
});
18+
19+
test('buildDockerLogsCommand probes plain Docker before sudo fallback', () => {
20+
const command = buildDockerLogsCommand('587abcdef123');
21+
22+
assert.match(command, /^sh -c /);
23+
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
24+
assert.match(command, /docker inspect 587abcdef123/);
25+
assert.match(command, /exec docker logs -f --tail 200 587abcdef123/);
26+
assert.match(command, /exec sudo docker logs -f --tail 200 587abcdef123/);
27+
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
28+
assert.doesNotMatch(command, /sudo -S/);
29+
assert.equal(command.includes('\n'), false);
30+
});

domain/systemManager/dockerShell.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,48 @@ export function sanitizeDockerContainerId(id: string): string {
55

66
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
77

8+
function shQuote(value: string): string {
9+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
10+
}
11+
12+
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
13+
const plainCommand = `docker ${dockerArgs}`;
14+
const sudoCommand = `sudo ${plainCommand}`;
15+
const script = [
16+
CLEAR_STARTUP_OUTPUT,
17+
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
18+
'_nc_docker_status=$?;',
19+
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
20+
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
21+
'case "$_nc_docker_lc" in',
22+
[
23+
'*permission\\ denied*docker\\ daemon*',
24+
'*docker\\ daemon*permission\\ denied*',
25+
'*permission\\ denied*docker.sock*',
26+
'*docker.sock*permission\\ denied*',
27+
'*permission\\ denied*/var/run/docker.sock*',
28+
'*/var/run/docker.sock*permission\\ denied*',
29+
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
30+
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
31+
].join('|') + `) exec ${sudoCommand} ;;`,
32+
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
33+
'esac',
34+
].join(' ');
35+
return `sh -c ${shQuote(script)}`;
36+
}
37+
838
/** Interactive shell into a container — prefer bash, fall back to sh. */
939
export function buildDockerExecShellCommand(containerId: string): string {
1040
const safeId = sanitizeDockerContainerId(containerId);
1141
if (!safeId) return 'echo "Invalid container id"';
12-
return `${CLEAR_STARTUP_OUTPUT} exec docker exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
42+
return buildDockerCommandWithSudoFallback(
43+
safeId,
44+
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
45+
);
1346
}
1447

1548
export function buildDockerLogsCommand(containerId: string): string {
1649
const safeId = sanitizeDockerContainerId(containerId);
1750
if (!safeId) return 'echo "Invalid container id"';
18-
return `${CLEAR_STARTUP_OUTPUT} exec docker logs -f --tail 200 ${safeId}`;
51+
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
1952
}

electron/bridges/sshBridge/startSession.cjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ function createStartSessionApi(ctx) {
3838
hostname: options.host || options.hostname || '',
3939
username: options.username || '',
4040
label: options.label || '',
41+
systemManagerSudoPassword: typeof options.sudoAutofillPassword === 'string' && options.sudoAutofillPassword.length > 0
42+
? options.sudoAutofillPassword
43+
: undefined,
4144
lastIdlePrompt: '',
4245
lastIdlePromptAt: 0,
4346
_promptTrackTail: '',

electron/bridges/systemManager/dockerOps.cjs

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,37 @@ function sanitizeImageRef(ref) {
1919
return trimmed || null;
2020
}
2121

22+
function isSuccessfulCommandResult(result) {
23+
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
24+
}
25+
26+
function dockerCommandError(result, fallback) {
27+
return (result?.stderr || result?.error || "").trim() || fallback;
28+
}
29+
30+
function isDockerSocketPermissionError(result) {
31+
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
32+
if (!text.includes("permission denied")) return false;
33+
return text.includes("docker daemon")
34+
|| text.includes("docker.sock")
35+
|| text.includes("/var/run/docker.sock")
36+
|| text.includes("connect to the docker daemon");
37+
}
38+
39+
function getSessionSudoPassword(session) {
40+
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
41+
? session.systemManagerSudoPassword
42+
: null;
43+
}
44+
45+
function buildDockerCommand(args) {
46+
return `docker ${args}`.trim();
47+
}
48+
49+
function buildSudoDockerCommand(args) {
50+
return `sudo -S -p '' ${buildDockerCommand(args)}`;
51+
}
52+
2253
function parseDockerContainers(stdout) {
2354
const containers = [];
2455
for (const line of (stdout || "").split("\n")) {
@@ -132,39 +163,49 @@ function summarizeContainerInspect(info) {
132163
};
133164
}
134165

135-
function createDockerOpsApi({ execOnSession }) {
166+
function createDockerOpsApi({ execOnSession, getSession }) {
136167
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
137-
const cmd = `docker ${args}`;
168+
const cmd = buildDockerCommand(args);
138169
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
170+
if (isSuccessfulCommandResult(result)) return result;
171+
172+
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
173+
174+
if (sudoPassword && isDockerSocketPermissionError(result)) {
175+
const sudoResult = await execOnSession(
176+
event,
177+
sessionId,
178+
buildSudoDockerCommand(args),
179+
timeoutMs,
180+
{ stdin: `${sudoPassword}\n` },
181+
);
182+
if (isSuccessfulCommandResult(sudoResult)) return sudoResult;
183+
return {
184+
success: false,
185+
error: dockerCommandError(sudoResult, `sudo docker exited with code ${sudoResult?.code}`),
186+
stderr: sudoResult?.stderr,
187+
};
188+
}
189+
139190
if (!result.success) return result;
140191
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
141192
return {
142193
success: false,
143-
error: (result.stderr || "").trim() || `docker exited with code ${result.code}`,
194+
error: dockerCommandError(result, `docker exited with code ${result.code}`),
144195
stderr: result.stderr,
145196
};
146197
}
147198
return result;
148199
}
149200

150201
async function listContainers(event, sessionId) {
151-
const result = await execOnSession(
152-
event,
153-
sessionId,
154-
"docker ps -a --format '{{json .}}'",
155-
12000,
156-
);
202+
const result = await runDocker(event, sessionId, "ps -a --format '{{json .}}'", 12000);
157203
if (!result.success) return { success: false, error: result.error };
158204
return { success: true, containers: parseDockerContainers(result.stdout) };
159205
}
160206

161207
async function listImages(event, sessionId) {
162-
const result = await execOnSession(
163-
event,
164-
sessionId,
165-
"docker images --format '{{json .}}'",
166-
12000,
167-
);
208+
const result = await runDocker(event, sessionId, "images --format '{{json .}}'", 12000);
168209
if (!result.success) return { success: false, error: result.error };
169210
return { success: true, images: parseDockerImages(result.stdout) };
170211
}
@@ -174,10 +215,10 @@ function createDockerOpsApi({ execOnSession }) {
174215
if (!sessionId) return { success: false, error: "Missing sessionId" };
175216
const ids = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : [];
176217
const idArg = ids.map((id) => sanitizeDockerId(id)).filter(Boolean).join(" ");
177-
const result = await execOnSession(
218+
const result = await runDocker(
178219
event,
179220
sessionId,
180-
`docker stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
221+
`stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
181222
15000,
182223
);
183224
if (!result.success) return { success: false, error: result.error };
@@ -188,7 +229,7 @@ function createDockerOpsApi({ execOnSession }) {
188229
const { sessionId, containerId } = payload || {};
189230
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
190231
const safeId = sanitizeDockerId(containerId);
191-
const result = await execOnSession(event, sessionId, `docker inspect ${safeId}`, 10000);
232+
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000);
192233
if (!result.success) return { success: false, error: result.error };
193234
try {
194235
const parsed = JSON.parse(result.stdout || "[]");
@@ -203,7 +244,7 @@ function createDockerOpsApi({ execOnSession }) {
203244
const { sessionId, imageId } = payload || {};
204245
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
205246
const safeId = sanitizeDockerId(imageId);
206-
const result = await execOnSession(event, sessionId, `docker image inspect ${safeId}`, 10000);
247+
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000);
207248
if (!result.success) return { success: false, error: result.error };
208249
try {
209250
const parsed = JSON.parse(result.stdout || "[]");

0 commit comments

Comments
 (0)