sido-askpass 0.1.1 → 0.3.0

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/README.md CHANGED
@@ -1,65 +1,185 @@
1
1
  # sido-askpass
2
2
 
3
- `SUDO_ASKPASS` shim for environments without a usable TTY — tmux, Herdr, and coding agents (Codex, OpenCode, Pi).
3
+ **Name:** sido-askpass
4
+ **Author:** patdx
5
+ **Repo:** https://github.com/patdx/sido-askpass
4
6
 
5
- When sudo needs a password and can't read from the terminal, it runs this program instead. Passwords never touch disk (FIFOs / stdout only).
7
+ `SUDO_ASKPASS` shim for tmux, Herdr, coding agents, and other environments
8
+ without a usable TTY.
6
9
 
7
- ## Install
10
+ When sudo uses askpass, `sido-askpass` opens a hidden password prompt and
11
+ returns the password to sudo. Passwords never touch a regular file.
12
+
13
+ Works with:
14
+
15
+ - **Linux** — hidden GUI or terminal password prompts
16
+ - **macOS** — hidden native password dialog
17
+ - **Codex automatic review** — approved retries can authenticate outside the sandbox
18
+ - **Codex Full Access** — sudo authentication from agent-run commands
19
+ - **tmux** — hidden popup prompt without stealing the agent's TTY
20
+ - **Herdr** — temporary password prompt pane that closes automatically
21
+ - **Raw terminals** — `/dev/tty` fallback when no GUI or multiplexer is available
22
+
23
+ Supports Linux and macOS. Requires **Node.js 24+**. Bash is required for the
24
+ tmux, Herdr, and `/dev/tty` backends; tmux and Herdr also require `mkfifo`.
25
+ Linux GUI prompting requires either `zenity` or `kdialog`. Windows is not
26
+ supported.
27
+
28
+ ## Quick start
29
+
30
+ ### User setup (recommended)
31
+
32
+ Install the command, add `SUDO_ASKPASS` to `~/.profile`, and load it into the
33
+ current shell:
8
34
 
9
35
  ```bash
10
- npm i -g sido-askpass
11
- # or: pnpm add -g sido-askpass
36
+ npm install -g sido-askpass && sido-askpass install --user && . ~/.profile
12
37
  ```
13
38
 
14
- Requires **Node.js 24+**.
39
+ If `.profile` already exports another `SUDO_ASKPASS`, installation prints the
40
+ old and new values before replacing it. Uninstall removes only the entry marked
41
+ as managed by sido.
42
+
43
+ ### System setup
44
+
45
+ Install the command and configure `Path askpass` in `/etc/sudo.conf`:
15
46
 
16
- ## Setup
47
+ ```bash
48
+ npm install -g sido-askpass && sido-askpass install --system
49
+ ```
17
50
 
18
- Session-only:
51
+ System setup runs `sudo tee` to update `/etc/sudo.conf`, so the installation
52
+ command itself must be run somewhere sudo can authenticate.
53
+
54
+ For a single session without changing a profile or sudo configuration:
19
55
 
20
56
  ```bash
21
57
  export SUDO_ASKPASS=$(command -v sido-askpass)
22
58
  ```
23
59
 
24
- Persist:
60
+ Alternatively, inject `SUDO_ASKPASS` for one command:
25
61
 
26
62
  ```bash
27
- sido-askpass install --user # ~/.profile → SUDO_ASKPASS
28
- # or
29
- sido-askpass install --system # /etc/sudo.conf → Path askpass
63
+ sido-askpass run sudo -A <command>
30
64
  ```
31
65
 
32
- Then:
66
+ Everything after `run` is run directly without shell parsing. `run` sets
67
+ `SUDO_ASKPASS` but does not add `-A` or otherwise rewrite the command, so pass
68
+ `-A` explicitly when sudo must use askpass even if a TTY is available.
69
+
70
+ ## Using sudo
71
+
72
+ For the most reliable behavior, explicitly tell sudo to use askpass:
33
73
 
34
74
  ```bash
35
75
  sudo -A <command>
36
76
  ```
37
77
 
38
- On modern Fedora, plain `sudo` often falls back to askpass automatically when there is no TTY.
78
+ On modern sudo installations, plain sudo commonly falls back to askpass when
79
+ there is no usable TTY. This automatic fallback has been verified on Fedora:
80
+
81
+ ```bash
82
+ sudo <command>
83
+ ```
84
+
85
+ TTY allocation is the important distinction:
86
+
87
+ - Without a TTY, plain sudo can automatically use the configured askpass
88
+ helper.
89
+ - With a real or pseudo-TTY, plain sudo prompts on that terminal instead and
90
+ does not call askpass.
91
+ - Some coding agents start background commands with a pseudo-TTY, so automatic
92
+ fallback cannot be assumed even when the agent UI has no visible terminal.
93
+ - `sudo -A` requests askpass regardless of whether a TTY exists.
94
+
95
+ For interactive shells, an optional alias can make askpass the default:
39
96
 
40
97
  ```bash
41
- sido-askpass status [--user|--system]
42
- sido-askpass uninstall --user|--system
98
+ alias sudo='sudo -A'
99
+ ```
100
+
101
+ Shell aliases usually do not affect commands launched directly by coding
102
+ agents or other non-interactive processes. `sido-askpass` may automate this in
103
+ the future, but it currently does not install an alias, rewrite sudo commands,
104
+ or add `-A`.
105
+
106
+ ## Codex test matrix
107
+
108
+ | Platform and terminal | Codex permissions | Prompt backend | Command | Result | Notes |
109
+ | ---------------------------- | ----------------- | -------------- | -------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
110
+ | Linux, tmux 3.7b | Automatic review | tmux popup | `sudo -A` | Tested | The sandbox cannot reach tmux; the approved retry outside the sandbox succeeds. |
111
+ | Linux, Herdr 0.7.5 | Automatic review | Herdr pane | `sudo -A` | Tested | The sandbox cannot reach Herdr; the approved retry outside the sandbox succeeds. |
112
+ | Fedora 44, raw Wayland shell | Automatic review | Zenity 4.2.2 | `sudo -A true` | Tested | 2026-07-30, sudo 1.9.17p2, Node.js 24.18.0; no tmux, Herdr, or stdin TTY. |
113
+ | Linux, any terminal | Full Access | First matching | `sudo -A` | Supported | sudo and the selected prompt backend can run outside the sandbox. |
114
+ | Linux, any terminal | Workspace-write | None | Any sudo | Unsupported | `no_new_privileges` blocks sudo; changing the askpass backend cannot bypass this. |
115
+ | Linux, pseudo-TTY command | Any sudo-capable | Askpass | Plain `sudo` | Skipped | sudo uses the allocated TTY instead of askpass; use `sudo -A` to force askpass. |
116
+ | Linux, raw terminal | Automatic review | `/dev/tty` | `sudo -A` | Untested | No tmux, Herdr, `DISPLAY`, or Wayland; requires an accessible controlling terminal. |
117
+ | Linux, pi (no TTY, GUI) | Automatic review | Zenity | Plain `sudo` | Tested | 2026-07-30, sudo 1.9.17p2, Node.js 24.18.0: no tmux, Herdr, or TTY; `DISPLAY`+zenity available. Plain sudo auto-fell back to askpass. |
118
+ | macOS | Any | AppleScript | `sudo -A` | Untested | The dialog backend exists, but Codex sandbox and approval behavior has not been tested. |
119
+
120
+ Codex automatic approval review does not widen the sandbox itself. It is useful
121
+ because it can approve an eligible retry outside the sandbox. Full Access also
122
+ works, but grants Codex substantially broader system access.
123
+
124
+ When a sandboxed Linux Codex process cannot reach tmux, `sido-askpass` exits
125
+ immediately instead of hanging and prints:
126
+
127
+ ```text
128
+ [sido] tmux access denied; retry the sudo command with escalated permissions
43
129
  ```
44
130
 
45
- ## How it prompts
131
+ This gives Codex an actionable reason to retry with escalated permission.
132
+
133
+ ## Other clients
134
+
135
+ | Client | Compatibility | Notes |
136
+ | -------- | ------------- | --------------------------------------------------------------------------------------------------------------------------- |
137
+ | Pi | Tested | No TTY — plain `sudo` auto-falls back to askpass; zenity GUI prompt. 2026-07-30, Fedora 44, Node.js 24.18.0, sudo 1.9.17p2. |
138
+ | OpenCode | Tested | 2026-07-30, Fedora 44, Node.js 24.18.0, `/dev/tty` fallback, `sudo -A whoami` → `root`. |
139
+
140
+ ## Prompt backend compatibility
46
141
 
47
- | Context | Method |
48
- | ---------------------------- | ------------------------------------------------------ |
49
- | `$TMUX` set | `tmux command-prompt -N` (secret), FIFO back to parent |
50
- | `$HERDR_ENV=1` | Herdr pane split + bash `read -s`, FIFO back |
51
- | macOS + GUI | `osascript` hidden dialog |
52
- | Linux + `$DISPLAY` / Wayland | `zenity` → `kdialog` |
53
- | else | `read -s` on `/dev/tty` |
142
+ The first matching context is used:
54
143
 
55
- ### tmux
144
+ | Context | Prompt method | Status |
145
+ | ---------------------------- | ------------------------------------------------ | ----------------------- |
146
+ | `$TMUX` set | tmux popup with Bash `read -s`; FIFO return | Tested on Linux |
147
+ | `$HERDR_ENV=1` | Temporary Herdr pane with `read -s`; FIFO return | Tested on Linux |
148
+ | macOS | Hidden `osascript` dialog | Implemented, unverified |
149
+ | Linux + `$DISPLAY` / Wayland | `zenity`, then `kdialog` | Tested on Linux |
150
+ | Other environment | Hidden `read -s` prompt on `/dev/tty` | Fallback |
56
151
 
57
- Inside a tmux session, sudo password entry uses tmux's built-in secret prompt no GUI dialog, no extra pane. Works well when agents or nested shells run under tmux and can't steal the TTY.
152
+ The prompt also shows the requesting command when the parent process command
153
+ line is available. If process inspection is blocked, it silently shows the
154
+ normal password prompt without the command. The displayed command includes its
155
+ arguments, which may reveal sensitive arguments to anyone who can see the
156
+ prompt.
58
157
 
59
- ### Agents / Herdr
158
+ ## Status and removal
159
+
160
+ Inspect the active environment and installed configuration:
161
+
162
+ ```bash
163
+ sido-askpass status
164
+ sido-askpass status --user
165
+ sido-askpass status --system
166
+ ```
167
+
168
+ Remove either setup:
169
+
170
+ ```bash
171
+ sido-askpass uninstall --user
172
+ sido-askpass uninstall --system
173
+ ```
60
174
 
61
- In Herdr (`HERDR_ENV=1`), a temporary pane collects the password. Same idea for Codex / OpenCode / Pi: point `SUDO_ASKPASS` at this binary so privilege prompts don't hang the session.
175
+ `--user` and `--system` are mutually exclusive for install, uninstall, and
176
+ status. Omitting both is supported only by status.
62
177
 
63
178
  ## Security
64
179
 
65
- Passwords are passed through kernel FIFOs (tmux / Herdr) or process stdout (GUI / TTY). Nothing is written to a regular file.
180
+ Password input is hidden. tmux and Herdr return the password through a kernel
181
+ FIFO; GUI and TTY backends return it through process stdout. Each tmux or Herdr
182
+ prompt uses a private temporary directory. Its FIFO, prompt, and helper script
183
+ are accessible only to the current user and are removed when prompting ends.
184
+ The temporary prompt file contains the displayed command and prompt, but never
185
+ the password.
@@ -3,19 +3,53 @@
3
3
  import { spawnSync } from 'node:child_process'
4
4
  import {
5
5
  existsSync,
6
+ mkdtempSync,
6
7
  readFileSync,
8
+ rmSync,
7
9
  writeFileSync,
8
- unlinkSync,
9
- chmodSync,
10
10
  } from 'node:fs'
11
11
  import { tmpdir, userInfo, hostname, homedir } from 'node:os'
12
12
  import { join, resolve } from 'node:path'
13
- import { randomUUID } from 'node:crypto'
14
13
  import { parseArgs } from 'node:util'
14
+ import package_json from '../package.json' with { type: 'json' }
15
15
 
16
16
  const self_path = resolve(process.argv[1] )
17
17
  const command = process.argv[2]
18
18
 
19
+ if (command === '_inner_prompt_receiver') {
20
+ const prompt_file = process.argv[3]
21
+ const fifo = process.argv[4]
22
+ if (!prompt_file || !fifo) process.exit(1)
23
+ inner_prompt_receiver(prompt_file, fifo)
24
+ process.exit(0)
25
+ }
26
+
27
+ // ── help / version ──────────────────────────────────────────────────────────
28
+
29
+ if (command === '--help' || command === '-h' || command === 'help') {
30
+ console.log(`sido-askpass — SUDO_ASKPASS shim for headless agent environments
31
+
32
+ Name: sido-askpass
33
+ Version: ${package_json.version}
34
+ Author: patdx
35
+ Repo: https://github.com/patdx/sido-askpass
36
+
37
+ Usage:
38
+ sido-askpass askpass mode (invoked by sudo)
39
+ sido-askpass install --user|--system
40
+ sido-askpass uninstall --user|--system
41
+ sido-askpass status [--user|--system]
42
+ sido-askpass run <command> [args...] run <command> with SUDO_ASKPASS set
43
+ sido-askpass --help
44
+ sido-askpass --version`)
45
+ process.exit(0)
46
+ }
47
+
48
+ if (command === '--version' || command === '-V' || command === 'version') {
49
+ console.log(package_json.version)
50
+ process.exit(0)
51
+ }
52
+
19
53
  // ── install / uninstall / status ────────────────────────────────────────────
20
54
 
21
55
  if (command === 'install' || command === 'uninstall') {
@@ -43,32 +77,77 @@ if (command === 'status') {
43
77
  user: { type: 'boolean' },
44
78
  system: { type: 'boolean' },
45
79
  },
46
- strict: false,
47
80
  })
81
+ if (values.user && values.system) {
82
+ console.error(`[sido] usage: ${self_path} status [--user|--system]`)
83
+ process.exit(1)
84
+ }
48
85
  const scope = values.user ? '--user' : values.system ? '--system' : undefined
49
86
  do_status(scope)
50
87
  process.exit(0)
51
88
  }
52
89
 
90
+ if (command === 'run') {
91
+ if (!process.argv[3]) {
92
+ console.error(`[sido] usage: ${self_path} run <command> [args...]`)
93
+ process.exit(1)
94
+ }
95
+ do_run(process.argv[3], process.argv.slice(4))
96
+ }
97
+
53
98
  // ── askpass mode ───────────────────────────────────────────────────────────
54
99
 
55
100
  const prompt = (command || `[sudo] password for ${userInfo().username}: `)
56
101
  .replace(/%u/g, userInfo().username)
57
102
  .replace(/%h/g, hostname())
58
-
59
- const isTmux = !!process.env.TMUX
60
- const isHerdr = process.env.HERDR_ENV === '1'
61
- const isMac = process.platform === 'darwin'
62
- const isLinux = process.platform === 'linux'
103
+ const requesting_command = get_requesting_command()
104
+ const display_prompt = requesting_command
105
+ ? `Command: ${requesting_command}\n${prompt}`
106
+ : prompt
107
+
108
+ const is_tmux = !!process.env.TMUX
109
+ const is_herdr = process.env.HERDR_ENV === '1'
110
+ const is_mac = process.platform === 'darwin'
111
+ const is_linux = process.platform === 'linux'
63
112
  const has_display = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)
64
- const can_gui = isMac || (isLinux && has_display)
65
-
66
- function tmp() {
67
- return join(tmpdir(), `sido-${randomUUID()}`)
113
+ const can_gui = is_mac || (is_linux && has_display)
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+ function create_prompt_resources() {
122
+ const dir = mkdtempSync(join(tmpdir(), 'sido-'))
123
+ const fifo = join(dir, 'password')
124
+ const result = spawnSync('mkfifo', ['-m', '600', fifo], {
125
+ stdio: ['pipe', 'pipe', 'pipe'],
126
+ })
127
+ if (result.error || result.status !== 0) {
128
+ rmSync(dir, { recursive: true, force: true })
129
+ const detail =
130
+ result.error?.message ||
131
+ result.stderr?.toString().trim() ||
132
+ 'unknown error'
133
+ throw new Error(`mkfifo failed: ${detail}`)
134
+ }
135
+ const resources = {
136
+ dir,
137
+ fifo,
138
+ prompt_file: join(dir, 'prompt'),
139
+ }
140
+ try {
141
+ writeFileSync(resources.prompt_file, display_prompt, { mode: 0o600 })
142
+ return resources
143
+ } catch (error) {
144
+ rmSync(dir, { recursive: true, force: true })
145
+ throw error
146
+ }
68
147
  }
69
148
 
70
- function mkfifo(p ) {
71
- spawnSync('mkfifo', [p], { stdio: ['pipe', 'pipe', 'pipe'] })
149
+ function remove_prompt_resources(resources ) {
150
+ rmSync(resources.dir, { recursive: true, force: true })
72
151
  }
73
152
 
74
153
  function read_stdout(r
@@ -79,9 +158,56 @@ function read_stdout(r
79
158
  return r.stdout.toString().replace(/\r?\n$/, '')
80
159
  }
81
160
 
161
+ function read_secret(prompt ) {
162
+ const r = spawnSync(
163
+ 'bash',
164
+ [
165
+ '-c',
166
+ 'read -s -p "$1" pw < /dev/tty && printf \'%s\\n\' "$pw"',
167
+ 'sido',
168
+ prompt,
169
+ ],
170
+ { stdio: ['ignore', 'pipe', 'inherit'] },
171
+ )
172
+ return read_stdout(r)
173
+ }
174
+
175
+ function inner_prompt_receiver(prompt_file , fifo ) {
176
+ const prompt = readFileSync(prompt_file, 'utf8')
177
+ writeFileSync(fifo, `${read_secret(prompt)}\n`)
178
+ }
179
+
180
+ function get_requesting_command() {
181
+ try {
182
+ let value
183
+ if (process.platform === 'linux') {
184
+ value = readFileSync(`/proc/${process.ppid}/cmdline`, 'utf8').replace(
185
+ /\0/g,
186
+ ' ',
187
+ )
188
+ } else {
189
+ const r = spawnSync(
190
+ 'ps',
191
+ ['-o', 'command=', '-p', String(process.ppid)],
192
+ { stdio: ['pipe', 'pipe', 'pipe'] },
193
+ )
194
+ if (r.status !== 0) return
195
+ value = r.stdout?.toString() ?? ''
196
+ }
197
+
198
+ value = value
199
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
200
+ .replace(/\s+/g, ' ')
201
+ .trim()
202
+ return value || undefined
203
+ } catch {
204
+ return
205
+ }
206
+ }
207
+
82
208
  function main() {
83
- if (isTmux) return void tmux_prompt()
84
- if (isHerdr) return void herdr_prompt()
209
+ if (is_tmux) return void tmux_prompt()
210
+ if (is_herdr) return void herdr_prompt()
85
211
  if (can_gui) return void gui_prompt()
86
212
  tty_prompt()
87
213
  }
@@ -94,15 +220,41 @@ function user_profile_path() {
94
220
  return join(homedir(), '.profile')
95
221
  }
96
222
 
223
+ function do_run(command , args ) {
224
+ const proc = spawnSync(command, args, {
225
+ env: { ...process.env, SUDO_ASKPASS: self_path },
226
+ stdio: 'inherit',
227
+ })
228
+ if (proc.error) {
229
+ console.error(`[sido] failed to run ${command}: ${proc.error.message}`)
230
+ process.exit(1)
231
+ }
232
+ if (proc.signal) {
233
+ process.kill(process.pid, proc.signal)
234
+ }
235
+ process.exit(proc.status ?? 1)
236
+ }
237
+
97
238
  function do_install(scope ) {
98
239
  if (scope === '--user') {
99
240
  const path = user_profile_path()
100
241
  const line = `export SUDO_ASKPASS="${self_path}"`
101
242
  let content = existsSync(path) ? readFileSync(path, 'utf-8') + '\n' : ''
102
- if (/^export SUDO_ASKPASS=/m.test(content)) {
103
- content = content.replace(/^export SUDO_ASKPASS=.*$/gm, line)
243
+ const managed_pattern = /^# sido\nexport SUDO_ASKPASS=.*$/gm
244
+ if (managed_pattern.test(content)) {
245
+ content = content.replace(managed_pattern, `# sido\n${line}`)
246
+ } else if (/^export SUDO_ASKPASS=.*$/m.test(content)) {
247
+ const old_values = [
248
+ ...content.matchAll(/^export SUDO_ASKPASS=(.*)$/gm),
249
+ ].map((match) => match[1])
250
+ for (const old_value of old_values) {
251
+ console.error(
252
+ `[sido] warning: replacing SUDO_ASKPASS=${old_value} with "${self_path}"`,
253
+ )
254
+ }
255
+ content = content.replace(/^export SUDO_ASKPASS=.*$/gm, `# sido\n${line}`)
104
256
  } else {
105
- content += `\n# sido\nexport SUDO_ASKPASS="${self_path}"\n`
257
+ content += `\n# sido\n${line}\n`
106
258
  }
107
259
  writeFileSync(path, content)
108
260
  console.error(`[sido] installed to ${path}`)
@@ -115,8 +267,19 @@ function do_install(scope ) {
115
267
  const r = spawnSync('cat', [path], { stdio: ['pipe', 'pipe', 'pipe'] })
116
268
  content = r.stdout?.toString() ?? ''
117
269
  }
118
- if (/^Path askpass /m.test(content)) {
119
- content = content.replace(/^Path askpass .*$/gm, line)
270
+ const managed_pattern = /^# sido\nPath askpass .*$/gm
271
+ if (managed_pattern.test(content)) {
272
+ content = content.replace(managed_pattern, `# sido\n${line}`)
273
+ } else if (/^Path askpass /m.test(content)) {
274
+ const old_values = [...content.matchAll(/^Path askpass (.*)$/gm)].map(
275
+ (match) => match[1],
276
+ )
277
+ for (const old_value of old_values) {
278
+ console.error(
279
+ `[sido] warning: replacing Path askpass ${old_value} with "${self_path}"`,
280
+ )
281
+ }
282
+ content = content.replace(/^Path askpass .*$/gm, `# sido\n${line}`)
120
283
  } else {
121
284
  content += `\n# sido\n${line}\n`
122
285
  }
@@ -141,8 +304,7 @@ function do_uninstall(scope ) {
141
304
  process.exit(1)
142
305
  }
143
306
  let content = readFileSync(path, 'utf-8')
144
- content = content.replace(/^# sido\n/gm, '')
145
- content = content.replace(/^export SUDO_ASKPASS=.*$/gm, '')
307
+ content = content.replace(/^# sido\nexport SUDO_ASKPASS=.*$\n?/gm, '')
146
308
  content = content.replace(/\n{3,}/g, '\n\n').trim()
147
309
  writeFileSync(path, content + '\n')
148
310
  console.error(`[sido] removed from ${path}`)
@@ -207,32 +369,73 @@ function do_status(scope ) {
207
369
  }
208
370
  }
209
371
 
372
+ // Background `cat` drains the FIFO back to the parent while a blocking command
373
+ // (tmux popup / herdr pane run) fills it. Shell job control reaps the reader on
374
+ // cancel — see AGENTS.md: native fs open() on a FIFO cannot be cancelled from JS,
375
+ // but the shell naturally reaps the background cat on cancel.
376
+ function fifo_drain_script(
377
+ fifo_ref ,
378
+ block_cmd ,
379
+ status_var ,
380
+ ) {
381
+ return [
382
+ `cat "${fifo_ref}" &`,
383
+ `reader=$!`,
384
+ block_cmd,
385
+ `${status_var}=$?`,
386
+ `if [ "$${status_var}" -ne 0 ]; then`,
387
+ ` kill "$reader" 2>/dev/null`,
388
+ ` wait "$reader" 2>/dev/null`,
389
+ ` exit "$${status_var}"`,
390
+ `fi`,
391
+ `wait "$reader" 2>/dev/null`,
392
+ ].join('\n')
393
+ }
394
+
210
395
  // ── tmux ────────────────────────────────────────────────────────────────────
211
396
  //
212
- // We use a single bash spawnSync combining a background cat on the FIFO
213
- // (reader) and the blocking tmux command-prompt (writer). Shell job control
214
- // (&, wait) keeps both in one sync call. Native fs.readFile on a FIFO
215
- // can't be cancelled mid-open (libuv threadpool blocking op), whereas the
216
- // shell naturally reaps the background cat on cancel.
397
+ // tmux command-prompt cannot hide input (-N means numeric-only, not secret).
398
+ // Use a popup running bash read -s instead. The password still travels only
399
+ // through a FIFO in a private temporary directory.
217
400
 
218
401
  function tmux_prompt() {
219
- const fifo = tmp()
220
- mkfifo(fifo)
402
+ const check = spawnSync('tmux', ['display-message', '-p', '#S'], {
403
+ stdio: ['pipe', 'pipe', 'pipe'],
404
+ })
405
+ if (check.status !== 0) {
406
+ const detail = check.stderr?.toString().trim()
407
+ if (detail) console.error(detail)
408
+ console.error(
409
+ '[sido] tmux access denied; retry the sudo command with escalated permissions',
410
+ )
411
+ process.exit(1)
412
+ }
221
413
 
222
- const safe_prompt = prompt.replace(/"/g, '\\"')
223
- const script = [
224
- `cat "${fifo}" &`,
225
- `reader=$!`,
226
- `tmux command-prompt -N -p "${safe_prompt}" "run-shell 'echo \\"%1\\" > \\"${fifo}\\"'"`,
227
- `wait "$reader" 2>/dev/null`,
414
+ let resources
415
+ try {
416
+ resources = create_prompt_resources()
417
+ } catch (error) {
418
+ console.error(`[sido] ${String(error)}`)
419
+ process.exit(1)
420
+ }
421
+
422
+ const block_cmd = [
423
+ `tmux display-popup -E -w 60% -h 5 \\`,
424
+ ` -e "SIDO_ASKPASS=$2" -e "SIDO_PROMPT=$3" -e "SIDO_FIFO=$1" \\`,
425
+ ` '"$SIDO_ASKPASS" _inner_prompt_receiver "$SIDO_PROMPT" "$SIDO_FIFO"'`,
228
426
  ].join('\n')
427
+ const script = fifo_drain_script('$1', block_cmd, 'popup_status')
229
428
 
230
- const r = spawnSync('bash', ['-c', script], {
231
- stdio: ['inherit', 'pipe', 'inherit'],
232
- })
429
+ let r
233
430
  try {
234
- unlinkSync(fifo)
235
- } catch {}
431
+ r = spawnSync(
432
+ 'bash',
433
+ ['-c', script, 'sido', resources.fifo, self_path, resources.prompt_file],
434
+ { stdio: ['inherit', 'pipe', 'inherit'] },
435
+ )
436
+ } finally {
437
+ remove_prompt_resources(resources)
438
+ }
236
439
  process.stdout.write(read_stdout(r))
237
440
  }
238
441
 
@@ -242,23 +445,6 @@ function tmux_prompt() {
242
445
  // call and reaps the background cat on cancel.
243
446
 
244
447
  function herdr_prompt() {
245
- const fifo = tmp()
246
- const prompt_file = tmp()
247
- const script_file = tmp()
248
-
249
- mkfifo(fifo)
250
- writeFileSync(prompt_file, prompt)
251
- writeFileSync(
252
- script_file,
253
- [
254
- `#!/usr/bin/env bash`,
255
- `prompt=$(cat "${prompt_file}")`,
256
- `read -s -p "$prompt" pw`,
257
- `echo "$pw" > "${fifo}"`,
258
- ].join('\n'),
259
- )
260
- chmodSync(script_file, 0o755)
261
-
262
448
  let direction = 'right'
263
449
  try {
264
450
  const layout = spawnSync('herdr', ['pane', 'layout', '--current'], {
@@ -284,30 +470,41 @@ function herdr_prompt() {
284
470
  ?.pane_id
285
471
  if (!pane_id) return void fallback_herdr()
286
472
 
287
- const runner_script = [
288
- `cat "${fifo}" &`,
289
- `reader=$!`,
290
- `herdr pane run "${pane_id}" bash "${script_file}"`,
291
- `wait "$reader" 2>/dev/null`,
292
- ].join('\n')
473
+ let resources
474
+ try {
475
+ resources = create_prompt_resources()
476
+ } catch (error) {
477
+ spawnSync('herdr', ['pane', 'close', pane_id], {
478
+ stdio: ['pipe', 'pipe', 'pipe'],
479
+ })
480
+ console.error(`[sido] ${String(error)}`)
481
+ process.exit(1)
482
+ }
293
483
 
294
- const r = spawnSync('bash', ['-c', runner_script], {
295
- stdio: ['inherit', 'pipe', 'inherit'],
296
- })
484
+ const block_cmd = `herdr pane run "$1" "$3" _inner_prompt_receiver "$4" "$2"`
485
+ const runner_script = fifo_drain_script('$2', block_cmd, 'run_status')
297
486
 
487
+ let r
298
488
  try {
299
- unlinkSync(fifo)
300
- } catch {}
301
- try {
302
- unlinkSync(prompt_file)
303
- } catch {}
304
- try {
305
- unlinkSync(script_file)
306
- } catch {}
307
- spawnSync('herdr', ['pane', 'close', pane_id], {
308
- stdio: ['pipe', 'pipe', 'pipe'],
309
- })
310
-
489
+ r = spawnSync(
490
+ 'bash',
491
+ [
492
+ '-c',
493
+ runner_script,
494
+ 'sido',
495
+ pane_id,
496
+ resources.fifo,
497
+ self_path,
498
+ resources.prompt_file,
499
+ ],
500
+ { stdio: ['inherit', 'pipe', 'inherit'] },
501
+ )
502
+ } finally {
503
+ remove_prompt_resources(resources)
504
+ spawnSync('herdr', ['pane', 'close', pane_id], {
505
+ stdio: ['pipe', 'pipe', 'pipe'],
506
+ })
507
+ }
311
508
  process.stdout.write(read_stdout(r))
312
509
  }
313
510
 
@@ -320,30 +517,34 @@ function fallback_herdr() {
320
517
  // ── GUI (macOS / Linux) ────────────────────────────────────────────────────
321
518
 
322
519
  function gui_prompt() {
323
- if (isMac) return void mac_gui()
520
+ if (is_mac) return void mac_gui()
324
521
  linux_gui()
325
522
  }
326
523
 
327
524
  function mac_gui() {
328
- const safe_prompt = prompt.replace(/"/g, '\\"')
329
525
  const r = spawnSync(
330
526
  'osascript',
331
527
  [
332
528
  '-e',
333
- `display dialog "${safe_prompt}" with hidden answer default answer ""`,
529
+ 'on run argv',
530
+ '-e',
531
+ 'display dialog (item 1 of argv) with hidden answer default answer ""',
334
532
  '-e',
335
533
  'text returned of result',
534
+ '-e',
535
+ 'end run',
536
+ display_prompt,
336
537
  ],
337
538
  { stdio: ['inherit', 'pipe', 'inherit'] },
338
539
  )
339
540
  if (r.status !== 0) process.exit(1)
340
- process.stdout.write(r.stdout?.toString().trim() ?? '')
541
+ process.stdout.write(r.stdout?.toString().replace(/\r?\n$/, '') ?? '')
341
542
  }
342
543
 
343
544
  function linux_gui() {
344
545
  for (const prog of [
345
- ['zenity', '--password', '--title', prompt],
346
- ['kdialog', '--password', prompt],
546
+ ['zenity', '--password', '--title', display_prompt],
547
+ ['kdialog', '--password', display_prompt],
347
548
  ]) {
348
549
  const [cmd, ...args] = prog
349
550
  const r = spawnSync(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] })
@@ -359,13 +560,7 @@ function linux_gui() {
359
560
  // ── TTY fallback ───────────────────────────────────────────────────────────
360
561
 
361
562
  function tty_prompt() {
362
- const safe_prompt = prompt.replace(/"/g, '\\"')
363
- const r = spawnSync(
364
- 'bash',
365
- ['-c', `read -s -p "${safe_prompt}" pw && echo "$pw"`],
366
- { stdio: ['inherit', 'pipe', 'inherit'] },
367
- )
368
- process.stdout.write(read_stdout(r))
563
+ process.stdout.write(read_secret(display_prompt))
369
564
  }
370
565
 
371
566
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sido-askpass",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "SUDO_ASKPASS shim for headless agent environments (tmux, Herdr, GUI, TTY)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,6 +38,7 @@
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/build.ts",
41
+ "e2e": "node e2e/run.ts",
41
42
  "typecheck": "tsc",
42
43
  "format": "prettier --write .",
43
44
  "format:check": "prettier --check ."