Commita38d8b29Recorded8 Sep 2026Repositoryslate

Support SSH ASKPASS and isolated GPG pinentry operations

Changed
 packages/slate-cli/design/auth-prompts.md          |  49 ++++++++++++++++++++++++++++++++++++++++++++-----
 packages/slate-cli/src/slate-cli/auth/askpass.sgl  | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------
 packages/slate-cli/src/slate-cli/auth/gpg-test.sgl |  74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 packages/slate-cli/src/slate-cli/auth/pinentry.sgl |  77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 packages/slate-cli/src/slate-cli/git/remote.sgl    |  29 ++++++++++++++++++++++++++---
 packages/slate-cli/src/slate-cli/main.sgl          |  15 ++++++++++++---
 packages/slate-cli/test/test-git-fetch.sgl         |  27 +++++++++++++++++++++++----
 packages/slate-cli/test/test-pinentry.sgl          |  43 +++++++++++++++++++++++++++++++++++++++++++
 packages/slate/test/git-auth-browser-gate.mjs      |  33 +++++++++++++++++++++++++++------
 packages/slate/test/git-auth-fixture.mjs           |  73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 packages/slate/test/gpg-auth-browser-gate.mjs      | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 packages/slate/test/ssh-auth-fixture.mjs           |  74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 12 files changed, 667 insertions(+), 81 deletions(-)
Diff
packages/slate-cli/design/auth-prompts.mdmodified
@@ -1,11 +1,13 @@
1
# Node authentication prompts
2
3
Slate's Git status Fetch command (`f` or the toolbar button) uses a real Git
−4
ASKPASS helper for HTTPS username/password or access-token authentication. It
+4
ASKPASS helpers for HTTPS credentials and SSH key passphrases, passwords or
+5
verification codes. It
6
fetches the current branch's configured remote, falling back to `origin`, and
7
refreshes status when finished. This first adapter requires a local browser
−7
connection to a Unix node. SSH, GPG, sudo, agent sessions and remote client
−8
routing are separate integrations.
+8
connection to a Unix node. GPG pinentry has an isolated real-agent test adapter;
+9
shared user agents, sudo, agent sessions and remote client routing remain separate
+10
integrations.
11
12
## Development wiring test
13
@@ -86,8 +88,8 @@ requests; bounded JSON frames and deadlines limit the IPC exchange. The node
88
executable doubles as the helper, entering helper mode before normal argument
89
parsing or logging. Its only successful stdout output is the requested answer.
90
−89
Only the Git subprocess and its descendants receive `GIT_ASKPASS` and the
−90
private socket/capability environment. Slate does not change the user's shell
+91
Only the Git subprocess and its descendants receive `GIT_ASKPASS`, `SSH_ASKPASS`,
+92
`SSH_ASKPASS_REQUIRE=force` and the private socket/capability environment. Slate does not change the user's shell
93
environment or Git configuration. Git still honors configured credential
94
helpers, including their own caching/storage policies. Git reads ASKPASS
95
automatically; callers need no special command syntax.
@@ -119,6 +121,43 @@ Git server, real ASKPASS subprocess, and browser input flow (requires Google
121
Chrome). `node packages/slate/test/git-auth-fixture.mjs` leaves that disposable
122
fixture running for manual testing; its credentials are defined in the fixture.
123
+124
## SSH and isolated GPG pinentry
+125
+126
SSH URL and scp-style remotes use the same operation-owned broker. OpenSSH
+127
controls key-passphrase retries, including their count. Confirmation and
+128
notification requests (including unknown host keys) are refused by the helper;
+129
this password sheet does not approve server identities. Establish host trust
+130
separately. Existing SSH configuration and agents continue to apply: an unlocked
+131
agent may authenticate without a prompt, and a shared GPG agent still invokes
+132
its configured pinentry.
+133
+134
Run `SLATE_TEST_TRANSPORT=ssh node packages/slate/test/git-auth-browser-gate.mjs`
+135
to exercise real Git Fetch through a disposable local OpenSSH server and an
+136
encrypted key, with the user's SSH agent disabled for that fixture. Its host
+137
key is pinned; the gate also removes that pin temporarily and verifies refusal.
+138
No user SSH configuration, known_hosts, authorized_keys or keys are changed.
+139
+140
`(slate-cli auth pinentry)` implements bounded Assuan GETPIN exchanges and
+141
percent-encodes response bytes into bounded data lines. Agent descriptions and
+142
errors are not forwarded to the browser. Unsupported confirmations, messages
+143
and new-passphrase/repeat setup return errors. It is not a full replacement for
+144
a desktop pinentry. The node enters pinentry mode before logging when
+145
`SLATE_PINENTRY_HELPER=1` is supplied by the owning operation.
+146
+147
`node packages/slate/test/gpg-auth-browser-gate.mjs` encrypts a fixture, then
+148
starts a node with explicit development-only `SLATE_GPG_TEST_FILE` configuration.
+149
On connection, the node starts a fresh GPG home/agent with its operation
+150
capability in the agent's launch environment and decrypts through real pinentry.
+151
The result checks both successful exit and the expected plaintext. Each test
+152
agent is stopped through its unique GPG home, and its socket directory and
+153
home are removed. No shared agent is reset or reconfigured.
+154
+155
For manual tests, run `SLATE_TEST_TRANSPORT=ssh node
+156
packages/slate/test/git-auth-fixture.mjs`, or `SLATE_TEST_GPG=1 node
+157
packages/slate/test/git-auth-fixture.mjs`. These flags belong only on disposable
+158
test previews. Fixture credentials are defined in that module. Ordinary node
+159
launches do not generate GPG test prompts.
+160
161
## Agent sessions and multiple clients
162
163
Agent launch environments can supply the same hook for ordinary Git commands.
packages/slate-cli/src/slate-cli/auth/askpass.sglmodified
@@ -4,7 +4,8 @@
4
(import (sigil async) (sigil socket) (sigil io) (sigil fs) (sigil path)
5
(sigil process) (sigil string) (sigil time) (sigil math) (sigil crypto)
6
(slate-cli auth ipc) (slate-cli auth prompts) (slate-cli listen))
−7
(export call-with-git-askpass askpass-helper-main)
+7
(export call-with-git-askpass call-with-auth-helper
+8
askpass-helper-main auth-helper-request helper-executable)
9
(begin
10
(define (close-socket socket)
11
(when socket (guard (e (#t #f)) (socket-close socket))))
@@ -22,44 +23,66 @@
23
(not (conn-state-get ctx "closed?" #f))
24
(member (car (socket-remote-address socket)) '("127.0.0.1" "::1")))))
25
−25
;; Called before ordinary CLI parsing/logging. Git supplies one prompt
−26
;; argument; only the answer is written to stdout, and errors are silent.
+26
;; Each helper exchanges one request with the operation that launched it.
+27
;; Pinentry can encode line breaks; Git/SSH stdout answers cannot contain them.
+28
(define (auth-helper-request prompt)
+29
(let ((path (getenv "SLATE_ASKPASS_SOCKET"))
+30
(token (getenv "SLATE_ASKPASS_TOKEN")))
+31
(unless (and path token (string? prompt) (<= (string-length prompt) 4096))
+32
(error "Invalid helper invocation"))
+33
(let ((socket (unix-connect path)))
+34
(unless socket (error "Authentication operation is no longer available"))
+35
(dynamic-wind
+36
(lambda () #f)
+37
(lambda ()
+38
(auth-ipc-write socket #{ token: token prompt: prompt })
+39
(let* ((reply (auth-ipc-read socket (+ (current-milliseconds) 125000)))
+40
(answer (dict-ref reply answer: #f)))
+41
(unless (and (string? answer) (<= (string-length answer) 4096))
+42
(error "Authentication cancelled"))
+43
answer))
+44
(lambda () (close-socket socket))))))
+45
+46
;; Called before ordinary CLI parsing/logging. SSH confirmation/notification
+47
;; requests are not password entry: never approve host keys through this door.
48
(define (askpass-helper-main args)
49
(guard (e (#t 1))
−29
(let ((path (getenv "SLATE_ASKPASS_SOCKET"))
−30
(token (getenv "SLATE_ASKPASS_TOKEN")))
−31
(unless (and (= (length args) 1) path token
−32
(<= (string-length (car args)) 4096))
−33
(error "Invalid helper invocation"))
−34
(with-async
−35
(let ((socket (unix-connect path)))
−36
(unless socket (error "Authentication operation is no longer available"))
−37
(dynamic-wind
−38
(lambda () #f)
−39
(lambda ()
−40
(auth-ipc-write socket #{ token: token prompt: (car args) })
−41
(let* ((reply (auth-ipc-read socket (+ (current-milliseconds) 125000)))
−42
(answer (dict-ref reply answer: #f)))
−43
(unless (and (string? answer) (<= (string-length answer) 4096)
−44
(not (string-contains? answer "\n"))
−45
(not (string-contains? answer "\r")))
−46
(error "Authentication cancelled"))
−47
(display answer)
−48
(newline)
−49
(flush-output-port (current-output-port))))
−50
(lambda () (close-socket socket)))))
−51
0)))
+50
(unless (and (= (length args) 1)
+51
(not (member (getenv "SSH_ASKPASS_PROMPT") '("confirm" "none")))
+52
(not (string-contains? (car args) "Are you sure you want to continue connecting")))
+53
(error "Unsupported helper invocation"))
+54
(with-async
+55
(let ((answer (auth-helper-request (car args))))
+56
(when (or (string-contains? answer "\n") (string-contains? answer "\r"))
+57
(error "Invalid answer"))
+58
(display answer)
+59
(newline)
+60
(flush-output-port (current-output-port))))
+61
0))
62
−53
(define (call-with-git-askpass ctx remote run)
−54
;; These presence-based Git flags cannot be disabled with a KEY=0 child
−55
;; override. The spawn API currently supports overrides, not removals.
+63
(define (call-with-git-askpass ctx remote run . transport)
+64
;; These presence-based Git flags cannot be disabled with KEY=0 overrides.
65
(when (or (getenv "GIT_CURL_VERBOSE") (getenv "GIT_SSL_NO_VERIFY"))
66
(error "operation-failed"
67
"Restart the Slate node without GIT_CURL_VERBOSE or GIT_SSL_NO_VERIFY to use authenticated Git"))
+68
(call-with-auth-helper ctx (string-append "Git fetch · " remote)
+69
(if (pair? transport) (car transport) "git")
+70
(lambda (environment cancelled?)
+71
(run
+72
(append environment
+73
(list (string-append "GIT_ASKPASS=" (helper-executable))
+74
(string-append "SSH_ASKPASS=" (helper-executable))
+75
"SSH_ASKPASS_REQUIRE=force" "GIT_TERMINAL_PROMPT=0"
+76
"GIT_TRACE=0" "GIT_TRACE_CURL=0"
+77
"GIT_TRACE_PACKET=0" "GIT_TRACE_PACKFILE=0"
+78
"GIT_TRACE2=0" "GIT_TRACE2_EVENT=0" "GIT_TRACE2_PERF=0"))
+79
cancelled?))))
+80
+81
(define (call-with-auth-helper ctx title kind run (keys: (acknowledge? #f)))
82
(unless (local-owner? ctx)
83
(error "credential-routing-unavailable"
−61
"Interactive Git authentication currently requires the local node connection"))
−62
(when (or (conn-state-get ctx "git-auth-operation" #f)
+84
"Interactive authentication currently requires the local node connection"))
+85
(when (or (conn-state-get ctx "auth-operation" #f)
86
(conn-state-get ctx "auth-prompt" #f))
87
(error "operation-busy" "Finish the current authentication operation first"))
88
;; mkdtemp creates this directory atomically with mode 0700 on Unix.
@@ -73,7 +96,8 @@
96
(done? #f)
97
(cancelled? #f)
98
(client #f)
−76
(last-id #f))
+99
(last-id #f)
+100
(result #f))
101
(unless listener (error "Private authentication sockets are unavailable"))
102
(define (cancel-operation!)
103
(set! cancelled? #t)
@@ -97,10 +121,15 @@
121
;; prompt may embed a username previously supplied privately.
122
(let* ((username? (string-starts-with? prompt "Username"))
123
(id (auth-prompt-start! ctx
−100
(string-append "Git fetch · " remote)
−101
(if username?
−102
"Git needs a username for this remote."
−103
"Git needs a password or access token for this remote.")
+124
title
+125
(cond
+126
((equal? kind "gpg") "GPG needs a passphrase to unlock this operation.")
+127
((equal? kind "ssh")
+128
(if (string-contains? prompt "passphrase")
+129
"SSH needs the passphrase for an encrypted key."
+130
"SSH needs a password or verification code."))
+131
(username? "Git needs a username for this remote.")
+132
(else "Git needs a password or access token for this remote."))
133
(min 120000 (max 0 (- deadline (current-milliseconds))))
134
(lambda (status answer id)
135
(unless (equal? status "answered")
@@ -111,7 +140,7 @@
140
#{ answer: answer } #{ cancel: #t })))
141
(close-socket socket))
142
#{ retry-of: last-id
−114
feedback: (if last-id "Git requested another credential." "") })))
+143
feedback: (if last-id "The program requested another credential or attempt." "") })))
144
(if id
145
(begin
146
(set! last-id id)
@@ -123,7 +152,7 @@
152
(wait))))
153
(set! cancelled? #t)))))
154
(close-socket socket)))
−126
(conn-state-set! ctx "git-auth-operation" #t)
+155
(conn-state-set! ctx "auth-operation" #t)
156
(socket-set-non-blocking! listener #t)
157
(dynamic-wind
158
(lambda () #f)
@@ -140,27 +169,27 @@
169
(set! client #f))
170
(sleep 0.01)))
171
(accept)))))
−143
(let ((result
−144
(run
−145
(list (string-append "GIT_ASKPASS=" (helper-executable))
−146
"SLATE_ASKPASS_HELPER=1"
−147
(string-append "SLATE_ASKPASS_SOCKET=" path)
−148
(string-append "SLATE_ASKPASS_TOKEN=" token)
−149
"GIT_TERMINAL_PROMPT=0"
−150
"GIT_TRACE=0" "GIT_TRACE_CURL=0"
−151
"GIT_TRACE_PACKET=0" "GIT_TRACE_PACKFILE=0"
−152
"GIT_TRACE2=0" "GIT_TRACE2_EVENT=0" "GIT_TRACE2_PERF=0")
−153
cancelled)))
−154
result))
+172
(set! result
+173
(run
+174
(list "SLATE_ASKPASS_HELPER=1"
+175
(string-append "SLATE_ASKPASS_SOCKET=" path)
+176
(string-append "SLATE_ASKPASS_TOKEN=" token))
+177
cancelled))
+178
result)
179
(lambda ()
180
(set! done? #t)
181
(auth-prompt-cancel! ctx)
182
(when last-id
183
(guard (e (#t #f))
184
(ws-send-control! ctx
−161
#{ t: "auth/prompt-result" id: last-id status: "completed" dismiss: #t })))
+185
(if acknowledge?
+186
#{ t: "auth/prompt-result" id: last-id status: "answered"
+187
accepted: (eq? result #t)
+188
message: (if (eq? result #t) "GPG decryption verified."
+189
"GPG operation cancelled or failed.") }
+190
#{ t: "auth/prompt-result" id: last-id status: "completed" dismiss: #t }))))
191
(close-socket listener)
192
(close-socket client)
−164
(conn-state-set! ctx "git-auth-operation" #f)
+193
(conn-state-set! ctx "auth-operation" #f)
194
(when (file-exists? path) (delete-file path))))))
195
recursive: #t))))
packages/slate-cli/src/slate-cli/auth/gpg-test.sgladded
@@ -0,0 +1,74 @@
+1
;;; Explicit development fixture: decrypt a test file using a fresh GPG home.
+2
;;; The short-lived agent belongs to this operation and is always stopped.
+3
(define-library (slate-cli auth gpg-test)
+4
(import (sigil async) (sigil fs) (sigil path) (sigil process) (sigil io) (sigil string)
+5
(slate-cli auth askpass) (slate-cli listen))
+6
(export auth-gpg-test!)
+7
(begin
+8
(define (gpg-control home . args)
+9
(process-communicate
+10
(apply process-spawn "gpgconf" "--homedir" home args)
+11
timeout: 5))
+12
(define (verify-decryption ctx file)
+13
(call-with-auth-helper ctx "GPG pinentry test" "gpg"
+14
(lambda (environment cancelled?)
+15
(call-with-temp-directory
+16
(lambda (home)
+17
(write-file-string (path-join home "gpg-agent.conf")
+18
(string-append "pinentry-program " (helper-executable) "\n"
+19
"default-cache-ttl 0\nmax-cache-ttl 0\nno-allow-external-cache\n"))
+20
(let* ((env (append environment (list "SLATE_PINENTRY_HELPER=1")))
+21
(agent (process-spawn "gpg-agent"
+22
"--homedir" home "--daemon"
+23
environment: env new-process-group: #t))
+24
(child #f)
+25
(done? #f))
+26
(dynamic-wind
+27
(lambda () #f)
+28
(lambda ()
+29
;; gpg-agent owns the capability in its launch environment;
+30
;; no attribution through a shared user's agent is assumed.
+31
(let* ((launched (process-communicate agent timeout: 5))
+32
(socket (string-trim
+33
(cadr (gpg-control home "--list-dirs" "agent-socket")))))
+34
(unless (equal? (car launched) '(exited 0))
+35
(error "Isolated GPG agent did not start"))
+36
(let wait ((attempt 0))
+37
(unless (file-exists? socket)
+38
(when (or (>= attempt 100) (cancelled?))
+39
(error "Isolated GPG agent socket did not appear"))
+40
(sleep 0.02)
+41
(wait (+ attempt 1)))))
+42
(set! child
+43
(process-spawn "gpg" "--no-options" "--homedir" home
+44
"--no-autostart" "--pinentry-mode" "ask"
+45
"--decrypt" file environment: env new-process-group: #t))
+46
(go
+47
(let watch ()
+48
(unless done?
+49
(if (cancelled?) (process-signal! child 'kill)
+50
(begin (sleep 0.02) (watch))))))
+51
(let ((result (process-communicate child timeout: 300)))
+52
(and (equal? (car result) '(exited 0))
+53
(equal? (cadr result) "Slate isolated GPG fixture\n"))))
+54
(lambda ()
+55
(set! done? #t)
+56
(when (and child (process-alive? child))
+57
(process-signal! child 'kill))
+58
;; Even --no-detach still forks in daemon mode. Address
+59
;; shutdown by our unique home, never the user's agent.
+60
(gpg-control home "--kill" "gpg-agent")
+61
(gpg-control home "--remove-socketdir")
+62
(when (process-alive? agent) (process-signal! agent 'kill))
+63
(when child (process-wait child))
+64
(process-wait agent)))))
+65
recursive: #t))
+66
acknowledge?: #t))
+67
(define (auth-gpg-test! ctx file)
+68
(unless (conn-state-get ctx "gpg-test-started?" #f)
+69
(conn-state-set! ctx "gpg-test-started?" #t)
+70
(go
+71
(sleep 3)
+72
(guard (e (#t (display "Isolated GPG test could not complete.\n"
+73
(current-error-port))))
+74
(verify-decryption ctx file)))))))
packages/slate-cli/src/slate-cli/auth/pinentry.sgladded
@@ -0,0 +1,77 @@
+1
;;; Minimal Assuan pinentry for an explicitly operation-owned GPG agent.
+2
;;; No generic confirmation is approved, and agent-provided text is never
+3
;;; forwarded into the browser. Ordinary user agents are not reconfigured.
+4
(define-library (slate-cli auth pinentry)
+5
(import (sigil async) (sigil io) (sigil string) (sigil process)
+6
(slate-cli auth askpass))
+7
(export pinentry-main pinentry-serve)
+8
(begin
+9
(define (read-command port)
+10
(let loop ((chars '()) (size 0))
+11
(when (>= size 16384) (error "Pinentry command exceeds limit"))
+12
(let ((ch (read-char port)))
+13
(cond
+14
((eof-object? ch) ch)
+15
((char=? ch #\newline) (list->string (reverse chars)))
+16
(else (loop (cons ch chars) (+ size 1)))))))
+17
(define (reply port text)
+18
(display text port)
+19
(newline port)
+20
(flush-output-port port))
+21
(define (data-reply port answer)
+22
;; Assuan lines are at most 1000 bytes including their terminator.
+23
;; Encoding each byte bounds chunks independently of Unicode width.
+24
(let ((bytes (string->utf8 answer)))
+25
(let loop ((i 0) (parts '()) (size 0))
+26
(cond
+27
((= i (bytevector-length bytes))
+28
(when (pair? parts)
+29
(reply port (string-append "D " (apply string-append (reverse parts))))))
+30
((>= size 960)
+31
(reply port (string-append "D " (apply string-append (reverse parts))))
+32
(loop i '() 0))
+33
(else
+34
(let* ((byte (bytevector-u8-ref bytes i))
+35
(hex (number->string byte 16))
+36
(piece (string-append "%" (if (< byte 16) "0" "") hex)))
+37
(loop (+ i 1) (cons piece parts) (+ size 3))))))
+38
(let wipe ((i 0))
+39
(when (< i (bytevector-length bytes))
+40
(bytevector-u8-set! bytes i 0)
+41
(wipe (+ i 1)))))
+42
(reply port "OK"))
+43
(define (pinentry-serve input output request)
+44
(reply output "OK Slate operation pinentry")
+45
(let loop ((requests 0))
+46
(let ((line (read-command input)))
+47
(unless (eof-object? line)
+48
(let ((command (car (string-split (string-trim line) " "))))
+49
(cond
+50
((equal? command "BYE") (reply output "OK"))
+51
((equal? command "GETPIN")
+52
(if (>= requests 8)
+53
(reply output "ERR 83886179 Operation cancelled")
+54
(guard (e (#t (reply output "ERR 83886179 Operation cancelled")))
+55
(let ((answer (request)))
+56
(unless (and (string? answer) (<= (string-length answer) 4096))
+57
(error "Invalid pinentry answer"))
+58
(data-reply output answer))))
+59
(loop (+ requests 1)))
+60
((member command '("OPTION" "SETDESC" "SETPROMPT" "SETTITLE"
+61
"SETERROR" "SETOK" "SETCANCEL" "SETNOTOK"
+62
"SETKEYINFO" "SETQUALITYBAR" "SETQUALITYBAR_TT" "RESET"))
+63
(reply output "OK")
+64
(loop requests))
+65
;; CONFIRM, MESSAGE, repeat/new-secret setup, and unknown
+66
;; extensions fail explicitly; never silently consent.
+67
(else
+68
(reply output "ERR 83886179 Unsupported pinentry interaction")
+69
(loop requests))))))))
+70
(define (pinentry-main)
+71
(guard (e (#t 1))
+72
(unless (and (getenv "SLATE_ASKPASS_SOCKET") (getenv "SLATE_ASKPASS_TOKEN"))
+73
(error "No owning authentication operation"))
+74
(with-async
+75
(pinentry-serve (current-input-port) (current-output-port)
+76
(lambda () (auth-helper-request "GPG passphrase"))))
+77
0))))
packages/slate-cli/src/slate-cli/git/remote.sglmodified
@@ -10,6 +10,27 @@
10
(if (= (dict-ref result exit: -1) 0)
11
(string-trim (dict-ref result stdout: ""))
12
"")))
+13
(define (ssh-url? url)
+14
(and (not (string-contains? url "\n"))
+15
(not (string-contains? url "\r"))
+16
(not (string-contains? url "\\"))
+17
(not (string-starts-with? url "-"))
+18
(let* ((uri? (string-starts-with? url "ssh://"))
+19
(authority
+20
(if uri?
+21
(car (string-split (substring url 6 (string-length url)) "/"))
+22
(car (string-split url ":"))))
+23
(parts (string-split authority "@"))
+24
(host (if (= (length parts) 2) (cadr parts) authority)))
+25
(and (or uri? (and (not (string-contains? url "://"))
+26
(string-contains? url ":")))
+27
(<= (length parts) 2)
+28
(> (string-length host) 0)
+29
(not (string-starts-with? host "-"))
+30
(not (string-contains? host "/"))
+31
(or (= (length parts) 1)
+32
(and (> (string-length (car parts)) 0)
+33
(not (string-contains? (car parts) ":"))))))))
34
(define (allowed-url? url)
35
(let* ((parts (string-split url "://"))
36
(scheme (if (= (length parts) 2) (car parts) ""))
@@ -41,9 +62,9 @@
62
(not (string-starts-with? remote "-")))
63
(error "operation-failed" "The configured fetch remote does not exist"))
64
(let ((url (read-git grants cwd (list "remote" "get-url" "--" remote))))
−44
(unless (allowed-url? url)
+65
(unless (or (ssh-url? url) (allowed-url? url))
66
(error "operation-failed"
−46
"Fetch currently supports HTTPS remotes without embedded credentials (and local HTTP test servers)"))
+67
"Fetch supports SSH and HTTPS remotes (and local HTTP test servers)"))
68
(let ((result
69
(execute cwd remote
70
(list "-c" "protocol.allow=never" "-c" "protocol.https.allow=always"
@@ -53,7 +74,9 @@
74
"-c" "http.followRedirects=initial"
75
"-c" "trace2.normalTarget=0" "-c" "trace2.eventTarget=0"
76
"-c" "trace2.perfTarget=0"
−56
"fetch" "--no-recurse-submodules" "--no-auto-maintenance" "--" remote))))
+77
"-c" "protocol.ssh.allow=always"
+78
"fetch" "--no-recurse-submodules" "--no-auto-maintenance" "--" remote)
+79
(if (ssh-url? url) "ssh" "git"))))
80
;; Never return raw Git diagnostics: helper/transport output can
81
;; contain credential context. Errors remain operation metadata.
82
(unless (and (= (dict-ref result exit: -1) 0)
packages/slate-cli/src/slate-cli/main.sglmodified
@@ -37,6 +37,8 @@
37
(slate-cli pair)
38
(slate-cli auth prompts)
39
(slate-cli auth askpass)
+40
(slate-cli auth pinentry)
+41
(slate-cli auth gpg-test)
42
(slate-cli git remote)
43
(slate-cli git exec)
44
(slate-cli peercred)
@@ -95,6 +97,8 @@
97
;; to the child: the child strips the four flag tokens before the
98
;; check counts its three positional arguments.
99
(define (main)
+100
(when (equal? (getenv "SLATE_PINENTRY_HELPER") "1")
+101
(exit (pinentry-main)))
102
(when (equal? (getenv "SLATE_ASKPASS_HELPER") "1")
103
(exit (askpass-helper-main (cdr (command-line)))))
104
(let ((parsed (extract-log-opts (cdr (command-line)))))
@@ -2752,7 +2756,11 @@
2756
(when (and (env-flag? "SLATE_DEV")
2757
(env-flag? "SLATE_ASKPASS_TEST")
2758
(not (conn-state-get ctx "direct-phase" #f)))
−2755
(auth-prompt-demo! ctx)))))
+2759
(auth-prompt-demo! ctx))
+2760
(when (and (env-flag? "SLATE_DEV")
+2761
(getenv "SLATE_GPG_TEST_FILE")
+2762
(not (conn-state-get ctx "direct-phase" #f)))
+2763
(auth-gpg-test! ctx (getenv "SLATE_GPG_TEST_FILE"))))))
2764
2765
;; ---- Resource-plane request handling (design §3.3) --------------
2766
;; Every req requires an authenticated principal; the grants table is
@@ -2765,12 +2773,13 @@
2773
(dict-set daemon git-fetch:
2774
(lambda (grants args)
2775
(git-fetch grants args
−2768
(lambda (cwd remote argv)
+2776
(lambda (cwd remote argv transport)
2777
(call-with-git-askpass ctx remote
2778
(lambda (environment cancelled?)
2779
(git-exec grants cwd argv timeout: 300 output-limit: 262144
2780
environment: environment cancelled?: cancelled? process-group?: #t
−2773
streaming?: #t))))))))
+2781
streaming?: #t))
+2782
transport))))))
2783
(define (handle-git-fetch! daemon ctx env)
2784
(if (conn-state-get ctx "git-fetch-busy" #f)
2785
(ws-send-control! ctx
packages/slate-cli/test/test-git-fetch.sglmodified
@@ -26,7 +26,7 @@
26
(let ((called #f))
27
(let ((reply
28
(git-fetch GRANTS #{ repo: REPO }
−29
(lambda (cwd remote args)
+29
(lambda (cwd remote args transport)
30
(set! called #t)
31
(assert-equal ROOT cwd)
32
(assert-equal "origin" remote)
@@ -45,15 +45,19 @@
45
(with-repo
46
(lambda (ROOT GRANTS REPO git)
47
(let ((called #f))
−48
(define (execute cwd remote args)
+48
(define (execute cwd remote args transport)
49
(set! called #t)
50
#{ exit: 0 })
−51
(git (list "remote" "set-url" "origin" "git@example.invalid:repo.git"))
+51
(git (list "remote" "set-url" "origin" "file:///tmp/repo.git"))
52
(assert-error (git-fetch GRANTS #{ repo: REPO } execute))
53
(git (list "remote" "set-url" "origin" "http://example.invalid/repo.git"))
54
(assert-error (git-fetch GRANTS #{ repo: REPO } execute))
55
(git (list "remote" "set-url" "origin" "https://user:secret@example.invalid/repo.git"))
56
(assert-error (git-fetch GRANTS #{ repo: REPO } execute))
+57
(git (list "remote" "set-url" "origin" "ssh://user:secret@example.invalid/repo.git"))
+58
(assert-error (git-fetch GRANTS #{ repo: REPO } execute))
+59
(git (list "remote" "set-url" "origin" "ssh://-bad/repo.git"))
+60
(assert-error (git-fetch GRANTS #{ repo: REPO } execute))
61
(git (list "remote" "set-url" "origin" "https://example.invalid/repo.git"))
62
(assert-error (git-fetch GRANTS #{ repo: REPO remote: "--all" } execute))
63
(assert-false called))
@@ -67,7 +71,7 @@
71
(grant-add! read-only "exec:full")
72
(assert-error
73
(git-fetch read-only #{ repo: REPO }
−70
(lambda (cwd remote args) (set! called #t) #{ exit: 0 })))
+74
(lambda (cwd remote args transport) (set! called #t) #{ exit: 0 })))
75
(assert-false called))
76
)))
77
@@ -79,3 +83,18 @@
83
(assert-true (dict-ref result cancelled?: #f))
84
(assert-false (dict-ref result timeout?: #f)))
85
)))
+86
+87
(test "fetch accepts SSH URL and scp-style remotes"
+88
(with-repo
+89
(lambda (ROOT GRANTS REPO git)
+90
(for-each
+91
(lambda (url)
+92
(git (list "remote" "set-url" "origin" url))
+93
(let ((reply
+94
(git-fetch GRANTS #{ repo: REPO }
+95
(lambda (cwd remote args transport)
+96
(assert-equal "ssh" transport)
+97
(assert-true (and (member "protocol.ssh.allow=always" args) #t))
+98
#{ exit: 0 }))))
+99
(assert-true (dict-ref reply applied: #f))))
+100
'("git@example.invalid:repo.git" "ssh://git@example.invalid:2222/repo.git")))))
packages/slate-cli/test/test-pinentry.sgladded
@@ -0,0 +1,43 @@
+1
(import (sigil test) (sigil io) (sigil string) (slate-cli auth pinentry))
+2
+3
(define (exchange commands request)
+4
(let ((output (open-output-string)))
+5
(pinentry-serve (open-input-string commands) output request)
+6
(get-output-string output)))
+7
+8
(test "pinentry escapes passphrase bytes and does not echo agent metadata"
+9
(let ((output
+10
(exchange "SETDESC private-description\nSETERROR private-error\nGETPIN\nBYE\n"
+11
(lambda () "a%\nλ"))))
+12
(assert-true (string-contains? output "D %61%25%0a%ce%bb\nOK\n"))
+13
(assert-false (string-contains? output "private-description"))
+14
(assert-false (string-contains? output "private-error"))))
+15
+16
(test "pinentry refuses confirmations and unsupported new-secret setup"
+17
(let ((calls 0))
+18
(let ((output
+19
(exchange "CONFIRM\nSETREPEAT again\nMESSAGE\nBYE\n"
+20
(lambda () (set! calls (+ calls 1)) "unused"))))
+21
(assert-equal 0 calls)
+22
(assert-equal 3 (length (filter
+23
(lambda (line) (string-starts-with? line "ERR "))
+24
(string-split output "\n")))))))
+25
+26
(test "pinentry cancellation returns an Assuan error without data"
+27
(let ((output (exchange "GETPIN\nBYE\n" (lambda () (error "private failure")))))
+28
(assert-true (string-contains? output "ERR 83886179"))
+29
(assert-false (string-contains? output "D "))
+30
(assert-false (string-contains? output "private failure"))))
+31
+32
(test "pinentry bounds protocol lines and request count"
+33
(assert-error (exchange (make-string 16384 #\x) (lambda () "unused")))
+34
(let ((calls 0))
+35
(exchange (string-append (apply string-append (make-list 10 "GETPIN\n")) "BYE\n")
+36
(lambda () (set! calls (+ calls 1)) "a"))
+37
(assert-equal 8 calls)))
+38
+39
(test "long Unicode answers fit Assuan's byte line limit"
+40
(let ((output (exchange "GETPIN\nBYE\n" (lambda () (make-string 4096 #\λ)))))
+41
(for-each
+42
(lambda (line) (assert-true (<= (bytevector-length (string->utf8 line)) 999)))
+43
(string-split output "\n"))))
packages/slate/test/git-auth-browser-gate.mjsmodified
@@ -7,7 +7,8 @@ import { spawn } from 'node:child_process';
7
import net from 'node:net';
8
import {createGitAuthFixture,username,password} from './git-auth-fixture.mjs';
9
−10
const fixture=await createGitAuthFixture();
+10
const ssh=process.env.SLATE_TEST_TRANSPORT==='ssh';
+11
const fixture=await createGitAuthFixture({transport:ssh?'ssh':'http'});
12
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
13
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-auth-chrome-'));
14
const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu',
@@ -91,9 +92,10 @@ try {
92
}
93
async function usernamePrompt(){
94
await key('f','KeyF',70);
−94
await waitFor(dialog+'.open && '+detail+".includes('username')",'Git username prompt');
+95
await waitFor(dialog+'.open && '+detail+".includes("+JSON.stringify(ssh?'passphrase':'username')+")",'Git username prompt');
96
}
97
async function passwordPrompt(){
+98
if(ssh)return;
99
await answer(username);
100
await waitFor(detail+".includes('password') && !"+field+'.readOnly','Git password prompt');
101
assert.equal(await evaluate(field+'.value'),'');
@@ -106,6 +108,14 @@ try {
108
await waitFor("!!document.querySelector('.folder')",'fixture folder');
109
await sgl('(slate-dispatch "git-status")');
110
await waitFor("!!document.querySelector('.git-status [data-slate-click=\"git-fetch\"]')",'Fetch control');
+111
if(ssh){
+112
fixture.trustHost(false);
+113
await key('f','KeyF',70);
+114
await waitFor('document.body.innerText.includes("Git fetch failed")','unknown SSH host refused');
+115
assert.equal(await evaluate(dialog+'.open'),false,'host confirmation never opens a password field');
+116
fixture.trustHost(true);
+117
console.log('PASS unknown SSH host refused without accepting its key');
+118
}
119
await usernamePrompt();
120
const dirs=fixture.ipcDirs();
121
assert.equal(dirs.length,1,'one private IPC directory per operation');
@@ -126,22 +136,33 @@ try {
136
await answer(password);
137
await waitFor('!'+dialog+'.open && document.body.innerText.includes("Fetched origin")','successful authenticated fetch');
138
assert.equal(fixture.git(fixture.repo,['rev-parse','refs/remotes/origin/main']),fixture.oid);
−129
assert.ok(fixture.authenticatedRequests()>0,'real HTTP server accepted credentials');
−130
assert.ok(fixture.redirects()>0,'Fetch followed the initial repository redirect');
+139
if(!ssh){
+140
assert.ok(fixture.authenticatedRequests()>0,'real HTTP server accepted credentials');
+141
assert.ok(fixture.redirects()>0,'Fetch followed the initial repository redirect');
+142
}
143
await poll(()=>fixture.ipcDirs().length===0);
144
for(const secret of [username,password]){
145
assert.equal(await evaluate('document.body.innerText.includes('+JSON.stringify(secret)+')'),false);
146
assert.equal(await evaluate('JSON.stringify({...localStorage,...sessionStorage}).includes('+JSON.stringify(secret)+')'),false);
147
assert.equal(fixture.logs().includes(secret),false,'node log excludes supplied answers');
148
}
−137
console.log('PASS real Fetch, private operation capability, username/password handoff, prompt dismissal and fetched ref');
+149
console.log(ssh?'PASS real SSH Fetch with encrypted key, private capability, prompt dismissal and fetched ref':
+150
'PASS real Fetch, private operation capability, username/password handoff, prompt dismissal and fetched ref');
151
152
await usernamePrompt();
153
await passwordPrompt();
154
await answer('deliberately-wrong-password');
+155
if(ssh){
+156
for(let i=0;i<2;i++){
+157
await waitFor(dialog+'.open && !'+field+'.readOnly', 'SSH retry after bad passphrase');
+158
assert.equal(await evaluate(field+'.value'),'');
+159
await answer('deliberately-wrong-password');
+160
}
+161
}
162
await waitFor('!'+dialog+'.open && document.body.innerText.includes("Git fetch failed")','Git rejects bad credentials');
163
await poll(()=>fixture.ipcDirs().length===0);
−144
console.log('PASS wrong credentials end the real Git operation without an automatic retry');
+164
console.log(ssh?'PASS SSH passphrase retries clear the input and stop after three wrong answers':
+165
'PASS wrong credentials end the real Git operation without an automatic retry');
166
167
await usernamePrompt();
168
await key('Escape','Escape',27);
packages/slate/test/git-auth-fixture.mjsmodified
@@ -7,19 +7,22 @@ import net from 'node:net';
7
import path from 'node:path';
8
import {spawn, spawnSync} from 'node:child_process';
9
import {pathToFileURL} from 'node:url';
+10
import {configureSSHFixture} from './ssh-auth-fixture.mjs';
11
12
export const username='slate-test-user';
13
export const password='slate-real-test-password';
14
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
−14
export async function createGitAuthFixture() {
+15
export async function createGitAuthFixture({transport='http',gpg=false}={}) {
16
const workspace=path.resolve(import.meta.dirname,'../../..');
17
fs.mkdirSync(path.join(workspace,'.sigil'),{recursive:true});
−17
const root=fs.mkdtempSync(path.join(workspace,'.sigil/git-auth-'));
+18
const root=fs.mkdtempSync(gpg?'/tmp/slate-gpg-':path.join(workspace,'.sigil/git-auth-'));
19
const seed=path.join(root,'seed'), repo=path.join(root,'repo'), bare=path.join(root,'remote.git');
20
for(const dir of [seed,repo,path.join(root,'tmp')])fs.mkdirSync(dir,{mode:0o700});
21
const environment={...process.env,GIT_CONFIG_GLOBAL:'/dev/null',GIT_CONFIG_NOSYSTEM:'1'};
22
delete environment.SLATE_ASKPASS_TEST;
23
delete environment.SLATE_ASKPASS_HELPER;
+24
delete environment.SLATE_PINENTRY_HELPER;
+25
delete environment.SLATE_GPG_TEST_FILE;
26
const git=(cwd,args)=>{
27
const r=spawnSync('git',args,{cwd,env:environment,encoding:'utf8'});
28
assert.equal(r.status,0,r.stderr);
@@ -58,32 +61,85 @@ export async function createGitAuthFixture() {
61
fs.createReadStream(file).pipe(res);
62
});
63
await new Promise(resolve=>httpServer.listen(0,'127.0.0.1',resolve));
−61
const remote='http://127.0.0.1:'+httpServer.address().port;
−62
git(repo,['remote','add','origin',remote+'/alias']);
+64
let ssh=null,remote;
+65
try {
+66
ssh=transport==='ssh'?await configureSSHFixture(root,bare,password):null;
+67
remote=ssh?ssh.remote:'http://127.0.0.1:'+httpServer.address().port+'/alias';
+68
if(ssh)Object.assign(environment,ssh.environment);
+69
git(repo,['remote','add','origin',remote]);
+70
if(gpg){
+71
const home=path.join(root,'encryption-home'),plain=path.join(root,'gpg-plain');
+72
fs.mkdirSync(home,{mode:0o700});fs.writeFileSync(plain,'Slate isolated GPG fixture\n');
+73
const file=path.join(root,'fixture.gpg');
+74
const r=spawnSync('gpg',['--homedir',home,'--batch','--yes','--pinentry-mode','loopback',
+75
'--passphrase-fd','0','--symmetric','--output',file,plain],
+76
{input:password+'\n',encoding:'utf8',timeout:15000});
+77
spawnSync('gpgconf',['--homedir',home,'--kill','gpg-agent']);
+78
spawnSync('gpgconf',['--homedir',home,'--remove-socketdir']);
+79
assert.equal(r.status,0,'isolated GPG fixture encryption failed');
+80
fs.rmSync(home,{recursive:true,force:true});fs.unlinkSync(plain);
+81
environment.SLATE_GPG_TEST_FILE=file;
+82
}
+83
}catch(e){
+84
if(ssh)await ssh.close();
+85
httpServer.closeAllConnections();
+86
await new Promise(resolve=>httpServer.close(resolve));
+87
fs.rmSync(root,{recursive:true,force:true});
+88
throw e;
+89
}
90
const portProbe=net.createServer();
91
await new Promise(resolve=>portProbe.listen(0,'127.0.0.1',resolve));
92
const nodePort=portProbe.address().port;
93
await new Promise(resolve=>portProbe.close(resolve));
+94
let appDir=path.join(workspace,'build/web');
+95
if(ssh && process.env.SLATE_TEST_MANUAL==='1'){
+96
appDir=path.join(root,'app');fs.mkdirSync(appDir);
+97
for(const name of fs.readdirSync(path.join(workspace,'build/web'))){
+98
if(name!=='index.html')fs.symlinkSync(path.join(workspace,'build/web',name),path.join(appDir,name));
+99
}
+100
const setup='(begin (import (slate root)) (register-root! sshfixture: #{ provider: local: home: '+
+101
JSON.stringify(repo)+' host: "localhost" label: "SSH authentication fixture" class: machine: }) (slate-dispatch "openroot:sshfixture"))';
+102
fs.writeFileSync(path.join(appDir,'fixture-start.mjs'),
+103
'let phase=0,tries=0;const timer=setInterval(()=>{if(++tries>1200){clearInterval(timer);return;}'+
+104
'if(!window.slateEval||document.getElementById("status")?.textContent!=="ready")return;'+
+105
'if(phase===0&&window.slateEval("(slate-node-connected?)")==="1"){window.slateEval('+JSON.stringify(setup)+');phase=1;}'+
+106
'else if(phase===1&&document.querySelector(".folder")){window.slateEval('+
+107
JSON.stringify('(slate-dispatch "git-status")')+');clearInterval(timer);}},100);');
+108
fs.writeFileSync(path.join(appDir,'index.html'),
+109
fs.readFileSync(path.join(workspace,'build/web/index.html'),'utf8').replace(/<\/body>\s*<\/html>\s*$/,
+110
'<script type="module" src="/fixture-start.mjs"></script></body></html>'));
+111
}
112
const logFile=path.join(root,'node.log'), log=fs.openSync(logFile,'w',0o600);
113
const node=spawn(path.join(workspace,'build/dev/bin/slate'),
−69
['--log-level','debug','serve','--port',String(nodePort),'--app-dir',path.join(workspace,'build/web')],
+114
['--log-level','debug','serve','--port',String(nodePort),'--app-dir',appDir],
115
{cwd:workspace,env:{...environment,SLATE_DEV:'1',XDG_STATE_HOME:path.join(root,'state'),
116
XDG_CONFIG_HOME:path.join(root,'config'),TMPDIR:path.join(root,'tmp')},
117
stdio:['ignore',log,log]});
118
fs.closeSync(log);
119
const nodeExit=new Promise(resolve=>node.once('exit',resolve));
120
const url='http://127.0.0.1:'+nodePort+'/';
−76
const close=async()=>{
+121
let closing;
+122
const close=()=>closing??=(async()=>{
123
node.kill('SIGTERM');
124
await Promise.race([nodeExit,sleep(3000)]);
+125
if(ssh)await ssh.close();
+126
// Also clean dedicated agents after a failed browser test kills the node.
+127
for(const name of fs.readdirSync(path.join(root,'tmp'))){
+128
const home=path.join(root,'tmp',name);
+129
if(fs.existsSync(path.join(home,'gpg-agent.conf'))){
+130
spawnSync('gpgconf',['--homedir',home,'--kill','gpg-agent'],{timeout:5000});
+131
spawnSync('gpgconf',['--homedir',home,'--remove-socketdir'],{timeout:5000});
+132
}
+133
}
134
httpServer.closeAllConnections();
135
await new Promise(resolve=>httpServer.close(resolve));
136
fs.rmSync(root,{recursive:true,force:true});
−82
};
+137
})();
138
try {
139
for(let i=0;i<100;i++){
140
if(node.exitCode!==null)throw new Error('Fixture node exited: '+fs.readFileSync(logFile,'utf8'));
141
try {if((await fetch(url)).ok)return {root,repo,remote,oid,url,close,git,
+142
transport,trustHost:ssh?.trustHost,
143
authenticatedRequests:()=>authenticatedRequests,
144
redirects:()=>redirects,
145
ipcDirs:()=>fs.readdirSync(path.join(root,'tmp')),
@@ -94,7 +150,8 @@ export async function createGitAuthFixture() {
150
}catch(e){await close();throw e;}
151
}
152
if(process.argv[1] && import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
−97
const fixture=await createGitAuthFixture();
+153
process.env.SLATE_TEST_MANUAL='1';
+154
const fixture=await createGitAuthFixture({transport:process.env.SLATE_TEST_TRANSPORT||'http',gpg:process.env.SLATE_TEST_GPG==='1'});
155
fs.writeFileSync('/tmp/slate-real-askpass-fixture.json',JSON.stringify({
156
url:fixture.url,repo:fixture.repo,root:fixture.root,remote:fixture.remote}));
157
console.log(JSON.stringify({url:fixture.url,repo:fixture.repo}));
packages/slate/test/gpg-auth-browser-gate.mjsadded
@@ -0,0 +1,121 @@
+1
// Real GPG decryption through a dedicated agent, Slate pinentry, and browser input.
+2
import assert from 'node:assert/strict';
+3
import fs from 'node:fs';
+4
import os from 'node:os';
+5
import path from 'node:path';
+6
import { spawn } from 'node:child_process';
+7
import {createGitAuthFixture,password} from './git-auth-fixture.mjs';
+8
+9
const fixture=await createGitAuthFixture({gpg:true});
+10
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+11
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-auth-chrome-'));
+12
const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu',
+13
'--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],
+14
{ stdio: ['ignore', 'ignore', 'pipe'] });
+15
const chromeExit = new Promise(resolve => chrome.once('exit', resolve));
+16
let socket;
+17
const errors = [];
+18
try {
+19
const activePort = path.join(profile, 'DevToolsActivePort');
+20
for (let i = 0; i < 120 && !fs.existsSync(activePort); i++) await sleep(50);
+21
assert.ok(fs.existsSync(activePort), 'Chrome did not expose DevTools');
+22
const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n');
+23
socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`);
+24
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
+25
let nextId = 0;
+26
const pending = new Map();
+27
socket.onmessage = event => {
+28
const message = JSON.parse(event.data);
+29
if (message.method === 'Runtime.exceptionThrown')
+30
errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text);
+31
if (message.id && pending.has(message.id)) {
+32
const pair = pending.get(message.id); pending.delete(message.id);
+33
message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result);
+34
}
+35
};
+36
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
+37
const id = ++nextId;
+38
const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 15000);
+39
pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); },
+40
reject: error => { clearTimeout(timer); reject(error); }});
+41
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
+42
});
+43
const target = await send('Target.createTarget', { url: 'about:blank' });
+44
const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true });
+45
const session = attached.sessionId;
+46
await send('Runtime.enable', {}, session);
+47
await send('Emulation.setDeviceMetricsOverride', {width:1280,height:800,deviceScaleFactor:1,mobile:false}, session);
+48
await send('Page.navigate', {
+49
url: fixture.url+'?rbdebug'
+50
}, session);
+51
const evaluate = async expression => {
+52
const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session);
+53
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
+54
return result.result.value;
+55
};
+56
async function waitFor(expression, label) {
+57
for (let i = 0; i < 360; i++) {
+58
if (await evaluate(expression)) return;
+59
await sleep(100);
+60
}
+61
const diagnostic = await evaluate(`({status:document.getElementById('status')?.textContent, body:document.body.innerText.slice(-2000)})`);
+62
throw new Error(`timeout waiting for ${label}; errors=${errors.join(' | ')}; page=${JSON.stringify(diagnostic)}`);
+63
}
+64
+65
+66
const field="document.querySelector('.auth-prompt input')";
+67
const dialog="document.querySelector('.auth-prompt')";
+68
async function ready(){
+69
await waitFor("document.getElementById('status')?.textContent==='ready'",'boot');
+70
await waitFor(dialog+".open && document.querySelector('.auth-prompt h2')?.textContent==='GPG pinentry test'",'real GPG pinentry');
+71
}
+72
async function key(name,code,n){
+73
await send('Input.dispatchKeyEvent',{type:'keyDown',key:name,code,windowsVirtualKeyCode:n},session);
+74
await send('Input.dispatchKeyEvent',{type:'keyUp',key:name,code,windowsVirtualKeyCode:n},session);
+75
}
+76
async function answer(value){
+77
await send('Input.insertText',{text:value},session);await key('Enter','Enter',13);
+78
}
+79
async function clean(){
+80
for(let i=0;i<200 && fixture.ipcDirs().length;i++)await sleep(50);
+81
assert.deepEqual(fixture.ipcDirs(),[],'isolated GPG home and broker socket removed');
+82
}
+83
await ready();
+84
await answer(password);
+85
await waitFor("document.querySelector('.auth-prompt-status')?.textContent.includes('Test passed')",'successful GPG decryption');
+86
assert.equal(await evaluate(field+'.value'),'');
+87
await clean();
+88
assert.equal(fixture.logs().includes(password),false);
+89
assert.equal(await evaluate('document.body.innerText.includes('+JSON.stringify(password)+')'),false);
+90
console.log('PASS real gpg-agent pinentry, decrypted fixture verification, empty input, private agent cleanup');
+91
await send('Page.reload',{ignoreCache:true},session);
+92
await ready();
+93
await answer('wrong-gpg-passphrase');
+94
// Symmetric decryption may fail immediately rather than retry; do not fake it.
+95
await waitFor("document.querySelector('.auth-prompt-status')?.textContent.includes('GPG operation cancelled or failed')",'GPG rejects wrong passphrase');
+96
await clean();
+97
console.log('PASS real GPG rejects wrong passphrase and cleans up');
+98
await send('Page.reload',{ignoreCache:true},session);
+99
await ready();
+100
await key('Escape','Escape',27);
+101
await clean();
+102
console.log('PASS GPG cancellation removes its agent and IPC');
+103
await send('Page.reload',{ignoreCache:true},session);
+104
await ready();
+105
await send('Page.navigate',{url:'about:blank'},session);
+106
await clean();
+107
console.log('PASS GPG disconnect removes its agent and IPC');
+108
await send('Page.navigate',{url:fixture.url+'?rbdebug'},session);
+109
await ready();
+110
await fixture.close();
+111
assert.equal(fs.existsSync(fixture.root),false);
+112
console.log('PASS fixture shutdown while GPG awaits input cleans up its private home');
+113
assert.deepEqual(errors,[]);
+114
} finally {
+115
await fixture.close();
+116
if (socket) socket.close();
+117
chrome.kill('SIGTERM');
+118
await Promise.race([chromeExit, sleep(2000)]);
+119
try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }
+120
catch (_) { /* Chrome helpers may finish touching the disposable profile late. */ }
+121
}
packages/slate/test/ssh-auth-fixture.mjsadded
@@ -0,0 +1,74 @@
+1
// A disposable SSH server, encrypted identity, and pinned host key. No user
+2
// SSH/GPG agent, known_hosts, ssh config, or authorized_keys is modified.
+3
import assert from 'node:assert/strict';
+4
import fs from 'node:fs';
+5
import net from 'node:net';
+6
import os from 'node:os';
+7
import path from 'node:path';
+8
import {spawn,spawnSync} from 'node:child_process';
+9
+10
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
+11
const quote=s=>"'"+s.replaceAll("'","'\\''")+"'";
+12
export async function configureSSHFixture(root,bare,password) {
+13
const key=path.join(root,'identity'),hostKey=path.join(root,'host-key');
+14
const keygen=(file,secret)=>{
+15
const r=spawnSync('ssh-keygen',['-q','-t','ed25519','-f',file],{
+16
input:secret+'\n'+secret+'\n',encoding:'utf8',timeout:10000,
+17
env:{...process.env,SSH_ASKPASS_REQUIRE:'never'}});
+18
assert.equal(r.status,0,'fixture key generation failed');
+19
};
+20
keygen(key,password);
+21
keygen(hostKey,'');
+22
const probe=net.createServer();
+23
await new Promise(resolve=>probe.listen(0,'127.0.0.1',resolve));
+24
const port=probe.address().port;
+25
await new Promise(resolve=>probe.close(resolve));
+26
const authorized=path.join(root,'authorized-keys'),known=path.join(root,'known-hosts');
+27
fs.copyFileSync(key+'.pub',authorized);
+28
const hostPublic=fs.readFileSync(hostKey+'.pub','utf8').trim().split(' ').slice(0,2).join(' ');
+29
const knownContent=`[127.0.0.1]:${port} ${hostPublic}\n`;
+30
fs.writeFileSync(known,knownContent);
+31
const config=path.join(root,'sshd-config');
+32
fs.writeFileSync(config,`ListenAddress 127.0.0.1
+33
Port ${port}
+34
HostKey ${hostKey}
+35
PidFile ${path.join(root,'sshd.pid')}
+36
AuthorizedKeysFile ${authorized}
+37
StrictModes no
+38
PasswordAuthentication no
+39
KbdInteractiveAuthentication no
+40
UsePAM no
+41
AllowUsers ${os.userInfo().username}
+42
AllowTcpForwarding no
+43
X11Forwarding no
+44
PermitTTY no
+45
ForceCommand git-upload-pack ${quote(bare)}
+46
LogLevel ERROR
+47
`);
+48
const r=spawnSync('which',['sshd'],{encoding:'utf8'});
+49
assert.equal(r.status,0,'sshd is required for the isolated SSH fixture');
+50
const logFile=path.join(root,'sshd.log'),log=fs.openSync(logFile,'w',0o600);
+51
const server=spawn(r.stdout.trim(),['-D','-e','-f',config],{stdio:['ignore',log,log]});
+52
fs.closeSync(log);
+53
const exit=new Promise(resolve=>server.once('exit',resolve));
+54
const close=async()=>{server.kill('SIGTERM');await Promise.race([exit,sleep(2000)]);};
+55
try {
+56
for(let i=0;i<100;i++){
+57
if(server.exitCode!==null)throw new Error('Fixture sshd exited: '+fs.readFileSync(logFile,'utf8'));
+58
const connected=await new Promise(resolve=>{
+59
const s=net.createConnection({host:'127.0.0.1',port});
+60
s.once('connect',()=>{s.destroy();resolve(true);});s.once('error',()=>resolve(false));
+61
});
+62
if(connected)break;
+63
await sleep(50);
+64
}
+65
const command=['ssh','-F','none','-i',key,'-o','IdentityAgent=none',
+66
'-o','IdentitiesOnly=yes','-o','BatchMode=no','-o','StrictHostKeyChecking=ask',
+67
'-o','UserKnownHostsFile='+known,'-o','GlobalKnownHostsFile=/dev/null',
+68
'-o','PreferredAuthentications=publickey','-o','NumberOfPasswordPrompts=3',
+69
'-o','ControlMaster=no','-o','ControlPath=none'].map(quote).join(' ');
+70
return {remote:`ssh://${os.userInfo().username}@127.0.0.1:${port}/fixture.git`,
+71
environment:{GIT_SSH_COMMAND:command},close,
+72
trustHost:yes=>fs.writeFileSync(known,yes?knownContent:'')};
+73
}catch(e){await close();throw e;}
+74
}