sido-askpass 0.5.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 +82 -15
- package/dist/sido-askpass.js +497 -37
- package/package.json +1 -1
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
|
|
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,
|
|
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
|
-
|
|
56
|
+
After first setup, rerun the detected managed user and/or system installation
|
|
57
|
+
without remembering its scope:
|
|
55
58
|
|
|
56
59
|
```bash
|
|
57
|
-
|
|
60
|
+
sido-askpass install
|
|
58
61
|
```
|
|
59
62
|
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
|
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
|
|
181
|
-
FIFO; GUI and TTY backends return it through process stdout.
|
|
182
|
-
prompt uses a private
|
|
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
|
-
|
|
185
|
-
the
|
|
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.
|
package/dist/sido-askpass.js
CHANGED
|
@@ -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...]
|
|
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 (
|
|
67
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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()
|