sido-askpass 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 patdx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # sido-askpass
2
+
3
+ `SUDO_ASKPASS` shim for environments without a usable TTY — tmux, Herdr, and coding agents (Codex, OpenCode, Pi).
4
+
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).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i -g sido-askpass
11
+ # or: pnpm add -g sido-askpass
12
+ ```
13
+
14
+ Requires **Node.js 24+**.
15
+
16
+ ## Setup
17
+
18
+ Session-only:
19
+
20
+ ```bash
21
+ export SUDO_ASKPASS=$(command -v sido-askpass)
22
+ ```
23
+
24
+ Persist:
25
+
26
+ ```bash
27
+ sido-askpass install --user # ~/.profile → SUDO_ASKPASS
28
+ # or
29
+ sido-askpass install --system # /etc/sudo.conf → Path askpass
30
+ ```
31
+
32
+ Then:
33
+
34
+ ```bash
35
+ sudo -A <command>
36
+ ```
37
+
38
+ On modern Fedora, plain `sudo` often falls back to askpass automatically when there is no TTY.
39
+
40
+ ```bash
41
+ sido-askpass status [--user|--system]
42
+ sido-askpass uninstall --user|--system
43
+ ```
44
+
45
+ ## How it prompts
46
+
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` |
54
+
55
+ ### tmux
56
+
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.
58
+
59
+ ### Agents / Herdr
60
+
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.
62
+
63
+ ## Security
64
+
65
+ Passwords are passed through kernel FIFOs (tmux / Herdr) or process stdout (GUI / TTY). Nothing is written to a regular file.
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process'
4
+ import {
5
+ existsSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ unlinkSync,
9
+ chmodSync,
10
+ } from 'node:fs'
11
+ import { tmpdir, userInfo, hostname, homedir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
13
+ import { randomUUID } from 'node:crypto'
14
+ import { parseArgs } from 'node:util'
15
+
16
+ const self_path = resolve(process.argv[1] )
17
+ const command = process.argv[2]
18
+
19
+ // ── install / uninstall / status ────────────────────────────────────────────
20
+
21
+ if (command === 'install' || command === 'uninstall') {
22
+ const { values } = parseArgs({
23
+ args: process.argv.slice(3),
24
+ options: {
25
+ user: { type: 'boolean' },
26
+ system: { type: 'boolean' },
27
+ },
28
+ })
29
+ if ((values.user && values.system) || (!values.user && !values.system)) {
30
+ console.error(`[sido] usage: ${self_path} ${command} --user|--system`)
31
+ process.exit(1)
32
+ }
33
+ const scope = values.user ? '--user' : '--system'
34
+ if (command === 'install') do_install(scope)
35
+ else do_uninstall(scope)
36
+ process.exit(0)
37
+ }
38
+
39
+ if (command === 'status') {
40
+ const { values } = parseArgs({
41
+ args: process.argv.slice(3),
42
+ options: {
43
+ user: { type: 'boolean' },
44
+ system: { type: 'boolean' },
45
+ },
46
+ strict: false,
47
+ })
48
+ const scope = values.user ? '--user' : values.system ? '--system' : undefined
49
+ do_status(scope)
50
+ process.exit(0)
51
+ }
52
+
53
+ // ── askpass mode ───────────────────────────────────────────────────────────
54
+
55
+ const prompt = (command || `[sudo] password for ${userInfo().username}: `)
56
+ .replace(/%u/g, userInfo().username)
57
+ .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'
63
+ 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()}`)
68
+ }
69
+
70
+ function mkfifo(p ) {
71
+ spawnSync('mkfifo', [p], { stdio: ['pipe', 'pipe', 'pipe'] })
72
+ }
73
+
74
+ function read_stdout(r
75
+
76
+
77
+ ) {
78
+ if (r.status !== 0 || !r.stdout || r.stdout.length === 0) process.exit(1)
79
+ return r.stdout.toString().replace(/\r?\n$/, '')
80
+ }
81
+
82
+ function main() {
83
+ if (isTmux) return void tmux_prompt()
84
+ if (isHerdr) return void herdr_prompt()
85
+ if (can_gui) return void gui_prompt()
86
+ tty_prompt()
87
+ }
88
+
89
+ function sudo_conf_path() {
90
+ return '/etc/sudo.conf'
91
+ }
92
+
93
+ function user_profile_path() {
94
+ return join(homedir(), '.profile')
95
+ }
96
+
97
+ function do_install(scope ) {
98
+ if (scope === '--user') {
99
+ const path = user_profile_path()
100
+ const line = `export SUDO_ASKPASS="${self_path}"`
101
+ 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)
104
+ } else {
105
+ content += `\n# sido\nexport SUDO_ASKPASS="${self_path}"\n`
106
+ }
107
+ writeFileSync(path, content)
108
+ console.error(`[sido] installed to ${path}`)
109
+ console.error(`[sido] run: source ${path}`)
110
+ } else {
111
+ const path = sudo_conf_path()
112
+ const line = `Path askpass ${self_path}`
113
+ let content = ''
114
+ if (existsSync(path)) {
115
+ const r = spawnSync('cat', [path], { stdio: ['pipe', 'pipe', 'pipe'] })
116
+ content = r.stdout?.toString() ?? ''
117
+ }
118
+ if (/^Path askpass /m.test(content)) {
119
+ content = content.replace(/^Path askpass .*$/gm, line)
120
+ } else {
121
+ content += `\n# sido\n${line}\n`
122
+ }
123
+ const proc = spawnSync('sudo', ['tee', path], {
124
+ input: content,
125
+ stdio: ['pipe', 'inherit', 'inherit'],
126
+ })
127
+ if (proc.status === 0) {
128
+ console.error(`[sido] installed to ${path}`)
129
+ } else {
130
+ console.error('[sido] install failed — do you have sudo?')
131
+ process.exit(1)
132
+ }
133
+ }
134
+ }
135
+
136
+ function do_uninstall(scope ) {
137
+ if (scope === '--user') {
138
+ const path = user_profile_path()
139
+ if (!existsSync(path)) {
140
+ console.error('[sido] nothing to uninstall')
141
+ process.exit(1)
142
+ }
143
+ let content = readFileSync(path, 'utf-8')
144
+ content = content.replace(/^# sido\n/gm, '')
145
+ content = content.replace(/^export SUDO_ASKPASS=.*$/gm, '')
146
+ content = content.replace(/\n{3,}/g, '\n\n').trim()
147
+ writeFileSync(path, content + '\n')
148
+ console.error(`[sido] removed from ${path}`)
149
+ } else {
150
+ const path = sudo_conf_path()
151
+ if (!existsSync(path)) {
152
+ console.error('[sido] nothing to uninstall')
153
+ process.exit(1)
154
+ }
155
+ const r = spawnSync('cat', [path], { stdio: ['pipe', 'pipe', 'pipe'] })
156
+ let content = r.stdout?.toString() ?? ''
157
+ content = content.replace(/^# sido\n/gm, '')
158
+ content = content.replace(/^Path askpass .*$/gm, '')
159
+ content = content.replace(/\n{3,}/g, '\n\n').trim()
160
+ const proc = spawnSync('sudo', ['tee', path], {
161
+ input: content + '\n',
162
+ stdio: ['pipe', 'inherit', 'inherit'],
163
+ })
164
+ if (proc.status === 0) {
165
+ console.error(`[sido] removed from ${path}`)
166
+ } else {
167
+ console.error('[sido] uninstall failed')
168
+ process.exit(1)
169
+ }
170
+ }
171
+ }
172
+
173
+ function do_status(scope ) {
174
+ if (!scope || scope === '--system') {
175
+ const sys_path = sudo_conf_path()
176
+ if (existsSync(sys_path)) {
177
+ const r = spawnSync('cat', [sys_path], {
178
+ stdio: ['pipe', 'pipe', 'pipe'],
179
+ })
180
+ const m = r.stdout?.toString().match(/^Path askpass (.*)$/m)
181
+ console.error(
182
+ m
183
+ ? `[sido] system askpass: ${m[1]}`
184
+ : `[sido] no system askpass in ${sys_path}`,
185
+ )
186
+ } else {
187
+ console.error(`[sido] ${sys_path} does not exist`)
188
+ }
189
+ }
190
+
191
+ if (!scope || scope === '--user') {
192
+ const user_path = user_profile_path()
193
+ if (existsSync(user_path)) {
194
+ const content = readFileSync(user_path, 'utf-8')
195
+ const m = content.match(/^export SUDO_ASKPASS=(.*)$/m)
196
+ console.error(
197
+ m
198
+ ? `[sido] user askpass in ~/.profile: ${m[1]}`
199
+ : `[sido] no SUDO_ASKPASS in ~/.profile`,
200
+ )
201
+ }
202
+ }
203
+
204
+ const env_active = process.env.SUDO_ASKPASS
205
+ if (env_active) {
206
+ console.error(`[sido] active (env): ${env_active}`)
207
+ }
208
+ }
209
+
210
+ // ── tmux ────────────────────────────────────────────────────────────────────
211
+ //
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.
217
+
218
+ function tmux_prompt() {
219
+ const fifo = tmp()
220
+ mkfifo(fifo)
221
+
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`,
228
+ ].join('\n')
229
+
230
+ const r = spawnSync('bash', ['-c', script], {
231
+ stdio: ['inherit', 'pipe', 'inherit'],
232
+ })
233
+ try {
234
+ unlinkSync(fifo)
235
+ } catch {}
236
+ process.stdout.write(read_stdout(r))
237
+ }
238
+
239
+ // ── Herdr ───────────────────────────────────────────────────────────────────
240
+ // Same bash spawnSync pattern as tmux: background cat on the FIFO + blocking
241
+ // herdr pane run. Shell job control (&, wait) keeps coordination in one sync
242
+ // call and reaps the background cat on cancel.
243
+
244
+ 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
+ let direction = 'right'
263
+ try {
264
+ const layout = spawnSync('herdr', ['pane', 'layout', '--current'], {
265
+ stdio: ['inherit', 'pipe', 'pipe'],
266
+ })
267
+ if (layout.status === 0) {
268
+ const info = JSON.parse(layout.stdout?.toString() ?? '{}')
269
+ if ((info.result?.pane?.width ?? 0) <= 150) direction = 'down'
270
+ }
271
+ } catch {}
272
+
273
+ console.error(
274
+ `[sido] password requested — see new pane (${direction === 'right' ? 'right' : 'bottom'})`,
275
+ )
276
+
277
+ const split = spawnSync(
278
+ 'herdr',
279
+ ['pane', 'split', '--current', '--direction', direction, '--cwd', '/'],
280
+ { stdio: ['inherit', 'pipe', 'pipe'] },
281
+ )
282
+ if (split.status !== 0) return void fallback_herdr()
283
+ const pane_id = JSON.parse(split.stdout?.toString() ?? '{}').result?.pane
284
+ ?.pane_id
285
+ if (!pane_id) return void fallback_herdr()
286
+
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')
293
+
294
+ const r = spawnSync('bash', ['-c', runner_script], {
295
+ stdio: ['inherit', 'pipe', 'inherit'],
296
+ })
297
+
298
+ 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
+
311
+ process.stdout.write(read_stdout(r))
312
+ }
313
+
314
+ function fallback_herdr() {
315
+ console.error('[sido] herdr pane split failed, falling back')
316
+ if (can_gui) return void gui_prompt()
317
+ tty_prompt()
318
+ }
319
+
320
+ // ── GUI (macOS / Linux) ────────────────────────────────────────────────────
321
+
322
+ function gui_prompt() {
323
+ if (isMac) return void mac_gui()
324
+ linux_gui()
325
+ }
326
+
327
+ function mac_gui() {
328
+ const safe_prompt = prompt.replace(/"/g, '\\"')
329
+ const r = spawnSync(
330
+ 'osascript',
331
+ [
332
+ '-e',
333
+ `display dialog "${safe_prompt}" with hidden answer default answer ""`,
334
+ '-e',
335
+ 'text returned of result',
336
+ ],
337
+ { stdio: ['inherit', 'pipe', 'inherit'] },
338
+ )
339
+ if (r.status !== 0) process.exit(1)
340
+ process.stdout.write(r.stdout?.toString().trim() ?? '')
341
+ }
342
+
343
+ function linux_gui() {
344
+ for (const prog of [
345
+ ['zenity', '--password', '--title', prompt],
346
+ ['kdialog', '--password', prompt],
347
+ ]) {
348
+ const [cmd, ...args] = prog
349
+ const r = spawnSync(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] })
350
+ if (r.status === 0) {
351
+ process.stdout.write(r.stdout?.toString().replace(/\n$/, '') ?? '')
352
+ return
353
+ }
354
+ }
355
+ console.error('[sido] no GUI dialog found')
356
+ tty_prompt()
357
+ }
358
+
359
+ // ── TTY fallback ───────────────────────────────────────────────────────────
360
+
361
+ 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))
369
+ }
370
+
371
+ main()
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "sido-askpass",
3
+ "version": "0.1.0",
4
+ "description": "SUDO_ASKPASS shim for headless agent environments (tmux, Herdr, GUI, TTY)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "sido-askpass": "dist/sido-askpass.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=24"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/patdx/sido-askpass.git"
21
+ },
22
+ "homepage": "https://github.com/patdx/sido-askpass#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/patdx/sido-askpass/issues"
25
+ },
26
+ "keywords": [
27
+ "sudo",
28
+ "askpass",
29
+ "SUDO_ASKPASS",
30
+ "tmux",
31
+ "herdr"
32
+ ],
33
+ "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
34
+ "scripts": {
35
+ "build": "node scripts/build.mjs",
36
+ "prepublishOnly": "pnpm typecheck && pnpm build",
37
+ "typecheck": "tsc",
38
+ "format": "prettier --write .",
39
+ "format:check": "prettier --check ."
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^24.0.0",
43
+ "amaro": "^1.1.11",
44
+ "prettier": "^3.9.6",
45
+ "typescript": "^7.0.2"
46
+ }
47
+ }