sido-askpass 0.6.0 → 0.7.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
@@ -18,10 +18,12 @@ Works with:
18
18
  - **Codex Full Access** — sudo authentication from agent-run commands
19
19
  - **tmux** — hidden popup prompt without stealing the agent's TTY
20
20
  - **Herdr** — temporary password prompt pane that closes automatically
21
- - **Raw terminals** — `/dev/tty` fallback when no GUI or multiplexer is available
21
+ - **Raw terminals** — `/dev/tty` fallback when an interactive shell owns the TTY
22
+ - **Watch mode** — approve from a second terminal when a TUI/agent owns the TTY (e.g. `ssh` → coding agent → `sudo` with no tmux/Herdr)
22
23
 
23
24
  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
+ tmux, Herdr, `/dev/tty`, and watch backends; tmux, Herdr, and watch also require
26
+ `mkfifo`.
25
27
  Linux GUI prompting requires either `zenity` or `kdialog`. Windows is not
26
28
  supported.
27
29
 
@@ -51,21 +53,31 @@ npm install -g sido-askpass && sido-askpass install --system
51
53
  System setup runs `sudo tee` to update `/etc/sudo.conf`, so the installation
52
54
  command itself must be run somewhere sudo can authenticate.
53
55
 
54
- For a single session without changing a profile or sudo configuration:
56
+ After first setup, rerun the detected managed user and/or system installation
57
+ without remembering its scope:
55
58
 
56
59
  ```bash
57
- export SUDO_ASKPASS=$(command -v sido-askpass)
60
+ sido-askpass install
58
61
  ```
59
62
 
60
- Alternatively, inject `SUDO_ASKPASS` for one command:
63
+ If no managed installation exists yet, `install` requires an explicit `--user`
64
+ or `--system`.
65
+
66
+ To use sido for one command without changing your profile or sudo
67
+ configuration:
61
68
 
62
69
  ```bash
63
- sido-askpass run sudo -A <command>
70
+ sido-askpass run -- sudo -A <command>
64
71
  ```
65
72
 
66
- Everything after `run` is run directly without shell parsing. `run` sets
73
+ Everything after `--` is run directly without shell parsing. `run` sets
67
74
  `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.
75
+ `-A` explicitly when sudo must use askpass even if a TTY is available. Select
76
+ an exact prompt adapter for the command with:
77
+
78
+ ```bash
79
+ sido-askpass run --adapter watch -- sudo -A <command>
80
+ ```
69
81
 
70
82
  ## Using sudo
71
83
 
@@ -139,7 +151,7 @@ This gives Codex an actionable reason to retry with escalated permission.
139
151
 
140
152
  ## Prompt backend compatibility
141
153
 
142
- The first matching context is used:
154
+ By default, `SIDO_ADAPTER=auto` uses the first matching context:
143
155
 
144
156
  | Context | Prompt method | Status |
145
157
  | ---------------------------- | ------------------------------------------------ | ----------------------- |
@@ -147,7 +159,30 @@ The first matching context is used:
147
159
  | `$HERDR_ENV=1` | Temporary Herdr pane with `read -s`; FIFO return | Tested on Linux |
148
160
  | macOS | Hidden `osascript` dialog | Implemented, unverified |
149
161
  | Linux + `$DISPLAY` / Wayland | `zenity`, then `kdialog` | Tested on Linux |
150
- | Other environment | Hidden `read -s` prompt on `/dev/tty` | Fallback |
162
+ | Canonical `/dev/tty` | Hidden `read -s` prompt on `/dev/tty` | Fallback |
163
+ | Raw/no TTY | Watch mode: park request + FIFO, `approve` | New |
164
+
165
+ Set `SIDO_ADAPTER` to bypass detection and require one exact adapter:
166
+
167
+ | Adapter | Behavior |
168
+ | ----------- | ----------------------------------------------------- |
169
+ | `auto` | Use the detection chain above; this is the default |
170
+ | `tmux` | Open a hidden prompt in a tmux popup |
171
+ | `herdr` | Open a hidden prompt in a temporary Herdr pane |
172
+ | `osascript` | Open the native macOS password dialog; requires macOS |
173
+ | `zenity` | Open a Zenity password dialog |
174
+ | `kdialog` | Open a KDE password dialog |
175
+ | `tty` | Read a hidden password from `/dev/tty` |
176
+ | `watch` | Wait for `sido-askpass approve` from another terminal |
177
+
178
+ An explicitly selected adapter either succeeds or exits with an error. It never
179
+ falls back to another adapter. `run` accepts the same selection as
180
+ `--adapter <name>`:
181
+
182
+ ```bash
183
+ SIDO_ADAPTER=osascript sudo -A <command>
184
+ sido-askpass run --adapter tty -- sudo -A <command>
185
+ ```
151
186
 
152
187
  The prompt also shows the requesting command when the parent process command
153
188
  line is available. If process inspection is blocked, it silently shows the
@@ -155,8 +190,38 @@ normal password prompt without the command. The displayed command includes its
155
190
  arguments, which may reveal sensitive arguments to anyone who can see the
156
191
  prompt.
157
192
 
193
+ ## Watch mode
194
+
195
+ When no inline prompt is safe — a coding agent's TUI owns the TTY in raw mode,
196
+ or there is no TTY at all — `sido-askpass` parks the request and waits for a
197
+ password from a second terminal:
198
+
199
+ ```bash
200
+ sido-askpass approve # approve the most recent pending request (one-shot)
201
+ sido-askpass watch # approve requests as they arrive (Ctrl-C to exit)
202
+ ```
203
+
204
+ The original terminal prints a hint telling you which command to run. Requests
205
+ live under `$XDG_RUNTIME_DIR/sido` (or `~/.cache/sido`); the password travels
206
+ through a kernel FIFO, never a file. `SIDO_ADAPTER=watch` selects watch mode;
207
+ `SIDO_WATCH_TIMEOUT=<sec>` sets how long it waits before giving up (default
208
+ 120).
209
+
158
210
  ## Status and removal
159
211
 
212
+ Upgrade an npm installation to the latest version and refresh the user
213
+ configuration:
214
+
215
+ ```bash
216
+ sido-askpass upgrade
217
+ ```
218
+
219
+ The upgrade command currently supports npm global installations only and
220
+ rejects commands installed by another package manager. It checks the latest
221
+ published version, upgrades only when that version is newer, then runs
222
+ `sido-askpass install` to refresh the existing managed configuration. If npm
223
+ fails, existing configuration is left unchanged.
224
+
160
225
  Inspect the active environment and installed configuration:
161
226
 
162
227
  ```bash
@@ -177,9 +242,11 @@ status. Omitting both is supported only by status.
177
242
 
178
243
  ## Security
179
244
 
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
245
+ Password input is hidden. tmux, Herdr, and watch mode return the password
246
+ through a kernel FIFO; GUI and TTY backends return it through process stdout.
247
+ Each tmux, Herdr, or watch prompt uses a private directory (mode 0700 under
248
+ `$XDG_RUNTIME_DIR/sido` for watch) with a mode-0600 FIFO. Its FIFO and prompt
183
249
  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.
250
+ Watch's approver feeds the password via stdin (`cat > fifo`), never argv, so it
251
+ never appears in `ps`. The prompt file contains the displayed command and
252
+ prompt, but never the password.
@@ -6,18 +6,33 @@
6
6
  import { spawnSync } from 'node:child_process'
7
7
  import {
8
8
  existsSync,
9
+ mkdirSync,
9
10
  mkdtempSync,
10
11
  readFileSync,
12
+ readdirSync,
13
+ realpathSync,
11
14
  rmSync,
15
+ statSync,
12
16
  writeFileSync,
13
17
  } from 'node:fs'
14
18
  import { tmpdir, userInfo, hostname, homedir } from 'node:os'
15
- import { join, resolve } from 'node:path'
19
+ import { join, resolve, sep } from 'node:path'
16
20
  import { parseArgs } from 'node:util'
17
21
  import package_json from '../package.json' with { type: 'json' }
18
22
 
19
23
  const self_path = resolve(process.argv[1] )
20
24
  const command = process.argv[2]
25
+ const adapters = [
26
+ 'auto',
27
+ 'tmux',
28
+ 'herdr',
29
+ 'osascript',
30
+ 'zenity',
31
+ 'kdialog',
32
+ 'tty',
33
+ 'watch',
34
+ ]
35
+
21
36
 
22
37
  if (command === '_inner_prompt_receiver') {
23
38
  const prompt_file = process.argv[3]
@@ -39,12 +54,30 @@ Repo: https://github.com/patdx/sido-askpass
39
54
 
40
55
  Usage:
41
56
  sido-askpass askpass mode (invoked by sudo)
42
- sido-askpass install --user|--system
57
+ sido-askpass install [--user|--system]
43
58
  sido-askpass uninstall --user|--system
59
+ sido-askpass upgrade upgrade an npm install and refresh its config
44
60
  sido-askpass status [--user|--system]
45
- sido-askpass run <command> [args...] run <command> with SUDO_ASKPASS set
61
+ sido-askpass run [--adapter <name>] -- <command> [args...]
62
+ run a command with SUDO_ASKPASS set
63
+ sido-askpass watch wait for and approve pending requests
64
+ sido-askpass approve approve the most recent pending request
46
65
  sido-askpass --help
47
- sido-askpass --version`)
66
+ sido-askpass --version
67
+
68
+ Adapters:
69
+ auto detect the best adapter (default)
70
+ tmux hidden prompt in a tmux popup
71
+ herdr hidden prompt in a temporary Herdr pane
72
+ osascript native macOS password dialog
73
+ zenity Zenity password dialog
74
+ kdialog KDE password dialog
75
+ tty hidden prompt on /dev/tty
76
+ watch approve from another terminal with "sido-askpass approve"
77
+
78
+ Env:
79
+ SIDO_ADAPTER=<name> select an adapter for askpass mode
80
+ SIDO_WATCH_TIMEOUT=<sec> approver wait timeout (default 120)`)
48
81
  process.exit(0)
49
82
  }
50
83
 
@@ -63,13 +96,18 @@ if (command === 'install' || command === 'uninstall') {
63
96
  system: { type: 'boolean' },
64
97
  },
65
98
  })
66
- if ((values.user && values.system) || (!values.user && !values.system)) {
67
- console.error(`[sido] usage: ${self_path} ${command} --user|--system`)
99
+ if (
100
+ (values.user && values.system) ||
101
+ (command === 'uninstall' && !values.user && !values.system)
102
+ ) {
103
+ const scope_usage =
104
+ command === 'install' ? '[--user|--system]' : '--user|--system'
105
+ console.error(`[sido] usage: ${self_path} ${command} ${scope_usage}`)
68
106
  process.exit(1)
69
107
  }
70
- const scope = values.user ? '--user' : '--system'
108
+ const scope = values.user ? '--user' : values.system ? '--system' : undefined
71
109
  if (command === 'install') do_install(scope)
72
- else do_uninstall(scope)
110
+ else do_uninstall(scope )
73
111
  process.exit(0)
74
112
  }
75
113
 
@@ -90,12 +128,23 @@ if (command === 'status') {
90
128
  process.exit(0)
91
129
  }
92
130
 
131
+ if (command === 'upgrade') {
132
+ do_upgrade()
133
+ }
134
+
93
135
  if (command === 'run') {
94
- if (!process.argv[3]) {
95
- console.error(`[sido] usage: ${self_path} run <command> [args...]`)
96
- process.exit(1)
97
- }
98
- do_run(process.argv[3], process.argv.slice(4))
136
+ const run = parse_run_args(process.argv.slice(3))
137
+ do_run(run.command, run.args, run.adapter)
138
+ }
139
+
140
+ if (command === 'watch') {
141
+ do_watch()
142
+ process.exit(0)
143
+ }
144
+
145
+ if (command === 'approve') {
146
+ do_approve()
147
+ process.exit(0)
99
148
  }
100
149
 
101
150
  // ── askpass mode ───────────────────────────────────────────────────────────
@@ -114,6 +163,7 @@ const is_mac = process.platform === 'darwin'
114
163
  const is_linux = process.platform === 'linux'
115
164
  const has_display = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)
116
165
  const can_gui = is_mac || (is_linux && has_display)
166
+ const selected_adapter = parse_adapter(process.env.SIDO_ADAPTER ?? 'auto')
117
167
 
118
168
 
119
169
 
@@ -121,8 +171,8 @@ const can_gui = is_mac || (is_linux && has_display)
121
171
 
122
172
 
123
173
 
124
- function create_prompt_resources() {
125
- const dir = mkdtempSync(join(tmpdir(), 'sido-'))
174
+ function create_prompt_resources(base = tmpdir()) {
175
+ const dir = mkdtempSync(join(base, 'sido-'))
126
176
  const fifo = join(dir, 'password')
127
177
  const result = spawnSync('mkfifo', ['-m', '600', fifo], {
128
178
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -161,7 +211,7 @@ function read_stdout(r
161
211
  return r.stdout.toString().replace(/\r?\n$/, '')
162
212
  }
163
213
 
164
- function read_secret(prompt ) {
214
+ function read_secret(prompt ) {
165
215
  const r = spawnSync(
166
216
  'bash',
167
217
  [
@@ -172,12 +222,15 @@ function read_secret(prompt ) {
172
222
  ],
173
223
  { stdio: ['ignore', 'pipe', 'inherit'] },
174
224
  )
175
- return read_stdout(r)
225
+ if (r.status !== 0 || !r.stdout || r.stdout.length === 0) return null
226
+ return r.stdout.toString().replace(/\r?\n$/, '')
176
227
  }
177
228
 
178
229
  function inner_prompt_receiver(prompt_file , fifo ) {
179
230
  const prompt = readFileSync(prompt_file, 'utf8')
180
- writeFileSync(fifo, `${read_secret(prompt)}\n`)
231
+ const pw = read_secret(prompt)
232
+ if (pw == null) process.exit(1)
233
+ writeFileSync(fifo, `${pw}\n`)
181
234
  }
182
235
 
183
236
  function get_requesting_command() {
@@ -209,10 +262,55 @@ function get_requesting_command() {
209
262
  }
210
263
 
211
264
  function main() {
265
+ if (selected_adapter !== 'auto') return void adapter_prompt(selected_adapter)
212
266
  if (is_tmux) return void tmux_prompt()
213
- if (is_herdr) return void herdr_prompt()
267
+ if (is_herdr) return void herdr_prompt(true)
214
268
  if (can_gui) return void gui_prompt()
215
- tty_prompt()
269
+ if (interactive_shell_tty() && tty_prompt()) return
270
+ watch_prompt()
271
+ }
272
+
273
+ function adapter_prompt(adapter ) {
274
+ if (adapter === 'tmux') return void tmux_prompt()
275
+ if (adapter === 'herdr') return void herdr_prompt(false)
276
+ if (adapter === 'osascript') {
277
+ if (!is_mac) adapter_unavailable(adapter, 'requires macOS')
278
+ return void mac_gui()
279
+ }
280
+ if (adapter === 'zenity')
281
+ return void exact_gui_prompt('zenity', [
282
+ '--password',
283
+ '--title',
284
+ display_prompt,
285
+ ])
286
+ if (adapter === 'kdialog')
287
+ return void exact_gui_prompt('kdialog', ['--password', display_prompt])
288
+ if (adapter === 'tty') {
289
+ if (!tty_prompt()) adapter_unavailable(adapter, 'could not read /dev/tty')
290
+ return
291
+ }
292
+ watch_prompt()
293
+ }
294
+
295
+ function adapter_unavailable(adapter , detail ) {
296
+ console.error(`[sido] ${adapter} adapter unavailable: ${detail}`)
297
+ process.exit(1)
298
+ }
299
+
300
+ // Returns true when /dev/tty is our controlling terminal and it is in canonical
301
+ // mode (a normal interactive shell owns it) — i.e. safe to read a password
302
+ // inline. A raw-mode tty means a TUI/agent owns the screen, and no tty at all
303
+ // means headless; both fall through to watch mode. `stty -a` is read-only, so
304
+ // it never disturbs the owning application's terminal state.
305
+ function interactive_shell_tty() {
306
+ const r = spawnSync('bash', ['-c', 'stty -a < /dev/tty 2>/dev/null'], {
307
+ stdio: ['ignore', 'pipe', 'inherit'],
308
+ })
309
+ if (r.status !== 0) return false
310
+ const out = r.stdout?.toString() ?? ''
311
+ // Linux prints `icanon`/`echo`; BSD/macOS prints `canon`/`echo`. Negated
312
+ // flags are prefixed with `-`, which the lookbehind excludes.
313
+ return /(?<!-)(?:i?canon)\b/.test(out) && /(?<!-)echo\b/.test(out)
216
314
  }
217
315
 
218
316
  function sudo_conf_path() {
@@ -223,9 +321,52 @@ function user_profile_path() {
223
321
  return join(homedir(), '.profile')
224
322
  }
225
323
 
226
- function do_run(command , args ) {
324
+ function parse_adapter(value ) {
325
+ if ((adapters ).includes(value)) return value
326
+ console.error(
327
+ `[sido] unknown adapter "${value}"; expected one of: ${adapters.join(', ')}`,
328
+ )
329
+ process.exit(1)
330
+ }
331
+
332
+ function parse_run_args(args )
333
+
334
+
335
+
336
+ {
337
+ const separator = args.indexOf('--')
338
+ if (separator === -1 || !args[separator + 1]) {
339
+ console.error(
340
+ `[sido] usage: ${self_path} run [--adapter <name>] -- <command> [args...]`,
341
+ )
342
+ process.exit(1)
343
+ }
344
+
345
+ let adapter_value
346
+ try {
347
+ adapter_value = parseArgs({
348
+ args: args.slice(0, separator),
349
+ options: { adapter: { type: 'string' } },
350
+ }).values.adapter
351
+ } catch (error) {
352
+ console.error(`[sido] ${String(error)}`)
353
+ process.exit(1)
354
+ }
355
+
356
+ return {
357
+ command: args[separator + 1] ,
358
+ args: args.slice(separator + 2),
359
+ ...(adapter_value ? { adapter: parse_adapter(adapter_value) } : {}),
360
+ }
361
+ }
362
+
363
+ function do_run(command , args , adapter ) {
227
364
  const proc = spawnSync(command, args, {
228
- env: { ...process.env, SUDO_ASKPASS: self_path },
365
+ env: {
366
+ ...process.env,
367
+ SUDO_ASKPASS: self_path,
368
+ ...(adapter ? { SIDO_ADAPTER: adapter } : {}),
369
+ },
229
370
  stdio: 'inherit',
230
371
  })
231
372
  if (proc.error) {
@@ -238,7 +379,174 @@ function do_run(command , args ) {
238
379
  process.exit(proc.status ?? 1)
239
380
  }
240
381
 
241
- function do_install(scope ) {
382
+ function compare_semver(left , right ) {
383
+ const parse = (value ) => {
384
+ const match = value.match(
385
+ /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
386
+ )
387
+ if (!match) return
388
+ return {
389
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
390
+ prerelease: match[4]?.split('.'),
391
+ }
392
+ }
393
+ const a = parse(left)
394
+ const b = parse(right)
395
+ if (!a || !b) return
396
+ for (let i = 0; i < a.core.length; i++) {
397
+ if (a.core[i] !== b.core[i] ) return a.core[i] < b.core[i] ? -1 : 1
398
+ }
399
+ if (!a.prerelease && !b.prerelease) return 0
400
+ if (!a.prerelease) return 1
401
+ if (!b.prerelease) return -1
402
+ const length = Math.max(a.prerelease.length, b.prerelease.length)
403
+ for (let i = 0; i < length; i++) {
404
+ const x = a.prerelease[i]
405
+ const y = b.prerelease[i]
406
+ if (x === undefined) return -1
407
+ if (y === undefined) return 1
408
+ if (x === y) continue
409
+ const x_numeric = /^\d+$/.test(x)
410
+ const y_numeric = /^\d+$/.test(y)
411
+ if (x_numeric && y_numeric) return Number(x) < Number(y) ? -1 : 1
412
+ if (x_numeric !== y_numeric) return x_numeric ? -1 : 1
413
+ return x < y ? -1 : 1
414
+ }
415
+ return 0
416
+ }
417
+
418
+ function has_managed_user_install() {
419
+ const path = user_profile_path()
420
+ return (
421
+ existsSync(path) &&
422
+ /^# sido\nexport SUDO_ASKPASS=.*$/m.test(readFileSync(path, 'utf8'))
423
+ )
424
+ }
425
+
426
+ function has_managed_system_install() {
427
+ const path = sudo_conf_path()
428
+ if (!existsSync(path)) return false
429
+ const result = spawnSync('cat', [path], { stdio: ['pipe', 'pipe', 'pipe'] })
430
+ return /^# sido\nPath askpass .*$/m.test(result.stdout?.toString() ?? '')
431
+ }
432
+
433
+ function require_npm_install() {
434
+ const root = spawnSync('npm', ['root', '--global'], {
435
+ stdio: ['inherit', 'pipe', 'inherit'],
436
+ encoding: 'utf8',
437
+ })
438
+ if (root.error || root.status !== 0) {
439
+ console.error('[sido] upgrade requires npm and an npm global installation')
440
+ process.exit(1)
441
+ }
442
+
443
+ try {
444
+ const package_path = realpathSync(join(root.stdout.trim(), 'sido-askpass'))
445
+ const executable_path = realpathSync(self_path)
446
+ if (
447
+ executable_path !== package_path &&
448
+ !executable_path.startsWith(`${package_path}${sep}`)
449
+ ) {
450
+ throw new Error('outside npm package')
451
+ }
452
+ } catch {
453
+ console.error(
454
+ '[sido] upgrade supports npm global installations only; use the original package manager to upgrade',
455
+ )
456
+ process.exit(1)
457
+ }
458
+ }
459
+
460
+ function do_upgrade() {
461
+ require_npm_install()
462
+ const latest_result = spawnSync(
463
+ 'npm',
464
+ ['view', 'sido-askpass@latest', 'version', '--json'],
465
+ { stdio: ['inherit', 'pipe', 'inherit'], encoding: 'utf8' },
466
+ )
467
+ if (latest_result.error) {
468
+ console.error(
469
+ `[sido] could not check npm for upgrades: ${latest_result.error.message}`,
470
+ )
471
+ process.exit(1)
472
+ }
473
+ if (latest_result.status !== 0) {
474
+ console.error(
475
+ `[sido] could not check npm for upgrades (status ${latest_result.status})`,
476
+ )
477
+ process.exit(latest_result.status ?? 1)
478
+ }
479
+
480
+ let latest
481
+ try {
482
+ const value = JSON.parse(latest_result.stdout)
483
+ if (typeof value !== 'string') throw new Error('not a version string')
484
+ latest = value
485
+ } catch {
486
+ console.error('[sido] npm returned an invalid latest version')
487
+ process.exit(1)
488
+ }
489
+
490
+ const comparison = compare_semver(package_json.version, latest)
491
+ if (comparison === undefined) {
492
+ console.error(
493
+ `[sido] cannot compare versions ${package_json.version} and ${latest}`,
494
+ )
495
+ process.exit(1)
496
+ }
497
+ if (comparison >= 0) {
498
+ console.error(
499
+ `[sido] already up to date (${package_json.version}; npm latest is ${latest})`,
500
+ )
501
+ process.exit(0)
502
+ }
503
+
504
+ console.error(
505
+ `[sido] upgrading ${package_json.version} to ${latest} with npm`,
506
+ )
507
+ const upgrade = spawnSync(
508
+ 'npm',
509
+ ['install', '--global', 'sido-askpass@latest'],
510
+ { stdio: 'inherit' },
511
+ )
512
+ if (upgrade.error) {
513
+ console.error(`[sido] npm upgrade failed: ${upgrade.error.message}`)
514
+ process.exit(1)
515
+ }
516
+ if (upgrade.status !== 0) {
517
+ console.error(`[sido] npm upgrade failed with status ${upgrade.status}`)
518
+ process.exit(upgrade.status ?? 1)
519
+ }
520
+
521
+ console.error('[sido] refreshing managed configuration')
522
+ const install = spawnSync(self_path, ['install'], {
523
+ stdio: 'inherit',
524
+ })
525
+ if (install.error) {
526
+ console.error(
527
+ `[sido] upgraded, but configuration refresh failed: ${install.error.message}`,
528
+ )
529
+ process.exit(1)
530
+ }
531
+ process.exit(install.status ?? 1)
532
+ }
533
+
534
+ function do_install(scope ) {
535
+ if (!scope) {
536
+ const scopes = [
537
+ ...(has_managed_user_install() ? ['--user'] : []),
538
+ ...(has_managed_system_install() ? ['--system'] : []),
539
+ ]
540
+ if (scopes.length === 0) {
541
+ console.error(
542
+ `[sido] no existing installation found; use: ${self_path} install --user|--system`,
543
+ )
544
+ process.exit(1)
545
+ }
546
+ for (const existing_scope of scopes) do_install(existing_scope)
547
+ return
548
+ }
549
+
242
550
  if (scope === '--user') {
243
551
  const path = user_profile_path()
244
552
  const line = `export SUDO_ASKPASS="${self_path}"`
@@ -447,7 +755,7 @@ function tmux_prompt() {
447
755
  // herdr pane run. Shell job control (&, wait) keeps coordination in one sync
448
756
  // call and reaps the background cat on cancel.
449
757
 
450
- function herdr_prompt() {
758
+ function herdr_prompt(allow_fallback ) {
451
759
  let direction = 'right'
452
760
  try {
453
761
  const layout = spawnSync('herdr', ['pane', 'layout', '--current'], {
@@ -468,10 +776,10 @@ function herdr_prompt() {
468
776
  ['pane', 'split', '--current', '--direction', direction, '--cwd', '/'],
469
777
  { stdio: ['inherit', 'pipe', 'pipe'] },
470
778
  )
471
- if (split.status !== 0) return void fallback_herdr()
779
+ if (split.status !== 0) return void fallback_herdr(allow_fallback)
472
780
  const pane_id = JSON.parse(split.stdout?.toString() ?? '{}').result?.pane
473
781
  ?.pane_id
474
- if (!pane_id) return void fallback_herdr()
782
+ if (!pane_id) return void fallback_herdr(allow_fallback)
475
783
 
476
784
  let resources
477
785
  try {
@@ -511,10 +819,12 @@ function herdr_prompt() {
511
819
  process.stdout.write(read_stdout(r))
512
820
  }
513
821
 
514
- function fallback_herdr() {
822
+ function fallback_herdr(allow_fallback ) {
823
+ if (!allow_fallback) adapter_unavailable('herdr', 'herdr pane split failed')
515
824
  console.error('[sido] herdr pane split failed, falling back')
516
825
  if (can_gui) return void gui_prompt()
517
- tty_prompt()
826
+ if (tty_prompt()) return
827
+ watch_prompt()
518
828
  }
519
829
 
520
830
  // ── GUI (macOS / Linux) ────────────────────────────────────────────────────
@@ -525,18 +835,20 @@ function gui_prompt() {
525
835
  }
526
836
 
527
837
  function mac_gui() {
838
+ const dialog_title = 'Administrator Authentication'
528
839
  const r = spawnSync(
529
840
  'osascript',
530
841
  [
531
842
  '-e',
532
843
  'on run argv',
533
844
  '-e',
534
- 'display dialog (item 1 of argv) with hidden answer default answer ""',
845
+ 'display dialog (item 1 of argv) with title (item 2 of argv) with icon caution with hidden answer default answer "" buttons {"Cancel", "Authenticate"} default button "Authenticate" cancel button "Cancel"',
535
846
  '-e',
536
847
  'text returned of result',
537
848
  '-e',
538
849
  'end run',
539
850
  display_prompt,
851
+ dialog_title,
540
852
  ],
541
853
  { stdio: ['inherit', 'pipe', 'inherit'] },
542
854
  )
@@ -550,20 +862,168 @@ function linux_gui() {
550
862
  ['kdialog', '--password', display_prompt],
551
863
  ]) {
552
864
  const [cmd, ...args] = prog
553
- const r = spawnSync(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] })
554
- if (r.status === 0) {
555
- process.stdout.write(r.stdout?.toString().replace(/\n$/, '') ?? '')
556
- return
557
- }
865
+ if (gui_program_prompt(cmd, args)) return
558
866
  }
559
867
  console.error('[sido] no GUI dialog found')
560
- tty_prompt()
868
+ if (tty_prompt()) return
869
+ watch_prompt()
870
+ }
871
+
872
+ function exact_gui_prompt(command , args ) {
873
+ if (!gui_program_prompt(command, args))
874
+ adapter_unavailable(
875
+ command ,
876
+ `${command} failed or was cancelled`,
877
+ )
878
+ }
879
+
880
+ function gui_program_prompt(command , args ) {
881
+ const r = spawnSync(command, args, {
882
+ stdio: ['inherit', 'pipe', 'inherit'],
883
+ })
884
+ if (r.status !== 0) return false
885
+ process.stdout.write(r.stdout?.toString().replace(/\n$/, '') ?? '')
886
+ return true
561
887
  }
562
888
 
563
889
  // ── TTY fallback ───────────────────────────────────────────────────────────
564
890
 
565
- function tty_prompt() {
566
- process.stdout.write(read_secret(display_prompt))
891
+ function tty_prompt() {
892
+ const pw = read_secret(display_prompt)
893
+ if (pw == null) return false
894
+ process.stdout.write(pw)
895
+ return true
896
+ }
897
+
898
+ // ── Watch mode (fallback for raw/headless ttys) ────────────────────────────
899
+ //
900
+ // When no inline surface is safe (a TUI/agent owns the tty in raw mode, or
901
+ // there is no tty at all), park the request in a user-private dir and block on
902
+ // a FIFO. A second terminal runs `sido approve` / `sido watch` to supply the
903
+ // password, which is written through the FIFO — never to disk, never to the
904
+ // agent transcript. This is agent-agnostic: it works for opencode, Codex, Pi,
905
+ // plain SSH, and headless scripts alike.
906
+
907
+ function sido_dir() {
908
+ const base = process.env.XDG_RUNTIME_DIR || join(homedir(), '.cache')
909
+ const dir = join(base, 'sido')
910
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
911
+ return dir
912
+ }
913
+
914
+ function watch_timeout_ms() {
915
+ const raw = Number(process.env.SIDO_WATCH_TIMEOUT)
916
+ if (Number.isFinite(raw) && raw > 0) return raw * 1000
917
+ return 120_000
918
+ }
919
+
920
+ function watch_prompt() {
921
+ let resources
922
+ try {
923
+ resources = create_prompt_resources(sido_dir())
924
+ } catch (error) {
925
+ console.error(`[sido] ${String(error)}`)
926
+ process.exit(1)
927
+ }
928
+
929
+ const timeout_ms = watch_timeout_ms()
930
+ console.error(
931
+ `[sido] password requested${requesting_command ? ` for: ${requesting_command}` : ''}`,
932
+ )
933
+ console.error(
934
+ `[sido] from another terminal run: ${self_path} approve (waiting up to ${Math.round(timeout_ms / 1000)}s)`,
935
+ )
936
+
937
+ // External `cat` opens the FIFO read-only and blocks until a writer (the
938
+ // approver) connects. spawnSync's timeout kills it if nobody approves in time
939
+ // — avoiding libuv's uncancellable threadpool open() on a FIFO.
940
+ let r
941
+ try {
942
+ r = spawnSync('bash', ['-c', 'cat "$1"', 'sido', resources.fifo], {
943
+ stdio: ['ignore', 'pipe', 'inherit'],
944
+ timeout: timeout_ms,
945
+ killSignal: 'SIGTERM',
946
+ })
947
+ } finally {
948
+ remove_prompt_resources(resources)
949
+ }
950
+
951
+ if (r .signal === 'SIGTERM' || r .status === null) {
952
+ console.error('[sido] timed out waiting for approver')
953
+ process.exit(1)
954
+ }
955
+ process.stdout.write(read_stdout(r ))
956
+ }
957
+
958
+ function do_watch() {
959
+ console.error(
960
+ `[sido] watching ${sido_dir()} for password requests (Ctrl-C to exit)`,
961
+ )
962
+ while (true) {
963
+ const req = newest_request()
964
+ if (req) handle_request(req)
965
+ else spawnSync('sleep', ['1'], { stdio: ['ignore', 'ignore', 'inherit'] })
966
+ }
967
+ }
968
+
969
+ function do_approve() {
970
+ const req = newest_request()
971
+ if (!req) {
972
+ console.error('[sido] no pending password requests')
973
+ process.exit(0)
974
+ }
975
+ handle_request(req)
976
+ }
977
+
978
+ function newest_request() {
979
+ let entries
980
+ try {
981
+ entries = readdirSync(sido_dir())
982
+ } catch {
983
+ return undefined
984
+ }
985
+ let newest
986
+ let newest_mtime = -Infinity
987
+ for (const entry of entries) {
988
+ if (!entry.startsWith('sido-')) continue
989
+ const path = join(sido_dir(), entry)
990
+ const m = statSync(path).mtimeMs
991
+ if (m > newest_mtime) {
992
+ newest_mtime = m
993
+ newest = path
994
+ }
995
+ }
996
+ return newest
997
+ }
998
+
999
+ function handle_request(req_dir ) {
1000
+ let prompt = '[sudo] password: '
1001
+ try {
1002
+ prompt = readFileSync(join(req_dir, 'prompt'), 'utf8')
1003
+ } catch {}
1004
+
1005
+ const password = read_secret(prompt)
1006
+ if (password == null) {
1007
+ console.error('[sido] no password entered')
1008
+ process.exit(1)
1009
+ }
1010
+
1011
+ // Feed the password via stdin, not argv, so it never appears in `ps`. The
1012
+ // redirection opens the FIFO write-only and blocks until the waiting shim's
1013
+ // reader connects; the timeout covers a shim that already exited/timed out.
1014
+ const r = spawnSync(
1015
+ 'bash',
1016
+ ['-c', 'cat > "$1"', 'sido', join(req_dir, 'password')],
1017
+ {
1018
+ input: `${password}\n`,
1019
+ stdio: ['pipe', 'pipe', 'inherit'],
1020
+ timeout: 5_000,
1021
+ },
1022
+ )
1023
+ console.error(
1024
+ r.status === 0 ? '[sido] password sent' : '[sido] request expired',
1025
+ )
1026
+ rmSync(req_dir, { recursive: true, force: true })
567
1027
  }
568
1028
 
569
1029
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sido-askpass",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "SUDO_ASKPASS shim for headless agent environments (tmux, Herdr, GUI, TTY)",
5
5
  "license": "MIT",
6
6
  "type": "module",