1 Commits
Author SHA1 Message Date
sneak 893b351eb6 Signals end every command, not only ssh to and ssh install (closes #48)
check / check (push) Failing after 3s
SIGINT, SIGTERM and SIGHUP were caught for the whole run, but only the
ssh and sftp children acted on them: the mnemonic prompt waited for
Enter, and an interrupted `age encrypt -o` put the encryption of the
cut-off input in place. Now `ssh to` and `ssh install` catch them from
once the mnemonic is read until their cleanup has run, and everywhere
else they end the tool at once, except while `age encrypt -o` or
`age decrypt -o` writes. There the work runs in the background, and a
signal that comes before it ends, or within a tenth of a second after,
removes the unfinished file and ends the tool with status 1, since
Ctrl-C on a pipeline can end the input just before the signal arrives.

Model: opus-5-5
2026-10-04 06:39:38 +00:00
12 changed files with 128 additions and 217 deletions
-3
View File
@@ -17,10 +17,7 @@
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules.
.git/config
.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
-3
View File
@@ -6,7 +6,4 @@ jobs:
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
# All history and tags, which `git describe --tags` needs.
fetch-depth: 0
- run: script/cibuild
-1
View File
@@ -17,7 +17,6 @@ linters:
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
+29 -27
View File
@@ -1,11 +1,11 @@
# The lint phase, the test phase and a development environment.
# script/lint and script/test each build one phase alone; a plain
# `docker build .` builds both, because the last stage copies a file from
# each. Formatting is checked on the host by script/fmt-check, not here.
# The lint phase, the test phase and the build. script/lint and
# script/test each build one phase alone; a plain `docker build .` builds
# both, because the build stage copies a file from each. Formatting is
# checked on the host by script/fmt-check, not here.
# Lint phase
# golangci/golangci-lint:v2.14.0, 2026-10-04
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
# golangci/golangci-lint:v2.12.2, 2026-09-07
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src
@@ -16,12 +16,13 @@ COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships.
# golang:1.26.8-trixie, 2026-10-04. It carries Go 1.26.8, the version
# script/bootstrap installs on the host; change both together, and the
# same image in the last stage.
FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 AS test
# Test phase
# golang:1.26-alpine, 2026-09-07. It carries Go 1.26.8, the version
# script/bootstrap installs on the host; change both together.
FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS test
# -race needs cgo, and cgo needs a C toolchain.
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
@@ -34,27 +35,21 @@ RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Development environment, and the last stage: a plain `docker build .`
# builds this one. It holds the source tree in /src, what
# script/bootstrap installs, and keyfunc built from that tree on the
# PATH. Nothing is wanted from either phase above; the copies are what
# make BuildKit build them first, so this stage cannot run unless lint
# and test passed.
# golang:1.26.8-trixie, 2026-10-04
FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang:1.26-alpine, 2026-09-07
FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
RUN apk add --no-cache make git
WORKDIR /src
# script/bootstrap needs only script/ and the dependency manifests.
COPY script/ script/
COPY go.mod go.sum package.json yarn.lock ./
RUN script/bootstrap
COPY go.mod go.sum ./
RUN go mod download
COPY . .
@@ -69,4 +64,11 @@ RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \
echo "no version could be derived although the build context carries .git" >&2; \
exit 1; \
fi; \
make build VERSION="$version" && mv keyfunc /usr/local/bin/keyfunc
make build VERSION="$version"
# alpine:3.23, 2026-09-07
FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
COPY --from=builder /src/keyfunc /usr/local/bin/keyfunc
ENTRYPOINT ["keyfunc"]
+10 -13
View File
@@ -290,15 +290,15 @@ passes through `ssh`'s own exit status.
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
with the status a shell gives a program killed by that signal (130 for SIGINT).
An interrupted `age encrypt -o` or `age decrypt -o` leaves no file. While it is
writing the file, the signal makes it remove the unfinished file, leave a file
already at the named path as it was, and exit with status 1. It puts the file in
place a tenth of a second after its input ends, and a signal in that time still
counts: Ctrl-C on a pipeline also ends the program feeding it, so the input can
end just before the signal arrives. While `ssh to` or `ssh install` has `ssh` or
`sftp` running, the signal ends that program instead, the tool removes its agent
socket or working files, and it exits with status 1, or for `ssh to` with
`ssh`'s own status if `ssh` reported one.
An interrupted `age encrypt -o` or `age decrypt -o` leaves no file: it removes
the unfinished file it was writing, leaves a file already at the named path as
it was, and exits with status 1. It puts the file in place a tenth of a second
after its input ends, and a signal in that time still counts: Ctrl-C on a
pipeline also ends the program feeding it, so the input can end just before the
signal arrives. While `ssh to` or `ssh install` has `ssh` or `sftp` running, the
signal ends that program instead, the tool removes its agent socket or working
files, and it exits with status 1, or for `ssh to` with `ssh`'s own status if
`ssh` reported one.
## Entrypoints
@@ -343,10 +343,7 @@ standard: most Makefile targets are thin shims over an executable in `script/`
- `script/docker` builds the Docker image, uncached, tagged with the project
name and stamped with the version `git describe` gives on the host. The image
cannot be built unless the `lint` and `test` phases pass, so a plain
`docker build .` runs them too. The image is a development environment, not a
runtime image: the Debian Go image with what `script/bootstrap` installs, the
source tree in `/src`, and `keyfunc` built from it on the `PATH`.
`docker run --rm -it keyfunc` opens a shell in it.
`docker build .` runs them too.
- `script/cibuild` is the CI build the Gitea workflow calls: it runs
`bootstrap`, then `check`, then builds the image as `script/docker` does.
- `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and
+36 -55
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-04
last_modified: 2026-10-02
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -160,7 +160,7 @@ style conventions are in separate documents:
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo
and the test phase is based on the Go image. The canonical Go repo
`Dockerfile`:
```dockerfile
@@ -173,9 +173,8 @@ style conventions are in separate documents:
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
# Test phase
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
@@ -193,8 +192,6 @@ style conventions are in separate documents:
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -239,23 +236,19 @@ style conventions are in separate documents:
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint phase with `apk add`.
- `.dockerignore` lets `.git` into the build context. It keeps out
`.git/config` and each submodule's `config` under `.git/modules/` at any
depth (`.git/modules/**/config`), which `git describe` does not need and
which can hold a credential: a password in a remote URL, or the token the
CI checkout step stores there. The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
`.git/config`, which `git describe` does not need and which can hold a
credential: a password in a remote URL, or the token the CI checkout step
stores there. The stage that compiles has `git` (the Debian Go image has
it; an alpine one needs `apk add --no-cache git`) and takes the version
from the `VERSION` build argument when one is given, otherwise from
`git describe --tags --always`. That gives the tag on a tagged commit; on
a later commit, the tag, the number of commits since it and the short
commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
reachable. `ARG VERSION` has no default, and the build fails if the
context carries `.git` and the version still comes out empty, `dev` or
`unknown`. A plain `docker build .` with no build arguments must succeed;
a Dockerfile that refuses an empty build argument drops that refusal and
keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step.
@@ -317,19 +310,17 @@ style conventions are in separate documents:
```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
cache, so the target cannot report a pass it did not earn, and the rerun
reproduces a failure instead of replaying it. It leaves the build cache
alone, so it costs the runtime of the suite and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Note that this is a second, independent cache, stacked below the Docker
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
it does not guarantee `go test` inside that step does any work, because the
`GOCACHE` baked into earlier image layers survives into the re-executed
step. They are two separate defects requiring two separate fixes, and a fix
for one must not be recorded as covering the other.
Python example:
@@ -460,18 +451,12 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
only pin, since no repo installs golangci-lint on the host: bumping the
version means changing it and nothing else.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -607,10 +592,10 @@ style conventions are in separate documents:
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
and language-specific config). Everything else goes in a subdirectory.
Canonical subdirectory names:
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
@@ -641,7 +626,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+12 -11
View File
@@ -3,18 +3,18 @@
package age
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/agekey"
"sneak.berlin/go/keyfunc/internal/cli/options"
"sneak.berlin/go/keyfunc/internal/cli/signals"
"sneak.berlin/go/keyfunc/internal/derive"
)
@@ -190,16 +190,17 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
// the new file in the named file's place only when the work succeeded,
// so a file that is already there survives a run that failed.
//
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context
// does. One that comes while the work runs, or within signalWait after
// it has ended, wins: the new file is removed and ErrInterrupted
// returned at once, without waiting for the work, which may be blocked
// reading its input.
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught. One that comes while
// the work runs, or within signalWait after it has ended, wins: the new
// file is removed and ErrInterrupted returned at once, without waiting
// for the work, which may be blocked reading its input.
func output(
name string, src io.Reader, work func(io.Writer, io.Reader) error,
) error {
interrupted, stop := signals.Context(context.Background())
defer stop()
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer signal.Stop(signals)
// The file is made in the same directory so that putting it in
// place is a rename and never a copy, and it is readable only by
@@ -216,11 +217,11 @@ func output(
select {
case failed := <-worked:
select {
case <-interrupted.Done():
case <-signals:
case <-time.After(signalWait):
return finish(file, name, failed)
}
case <-interrupted.Done():
case <-signals:
}
return finish(file, name, ErrInterrupted)
+26 -64
View File
@@ -134,40 +134,16 @@ func TestASignalAsTheInputEndsLeavesNoFile(t *testing.T) {
}
}
func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
directory := t.TempDir()
named := filepath.Join(directory, "notes")
// nohup starts the tool with SIGHUP ignored. A tool that caught it
// anyway would turn it back on and be ended by it.
command, producer := writing(
t, directory, "the secret\n",
"nohup", os.Args[0], "age", "encrypt", "-o", named,
)
require.NoError(t, command.Process.Signal(syscall.SIGHUP))
require.NoError(t, producer.Close())
waitForTool(t, "SIGHUP under nohup", command)
require.Equal(t, 0, command.ProcessState.ExitCode())
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Len(t, left, 1)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", named))
}
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
// operation says, writing into a directory of its own, and once it has
// begun writing sends it the signal, then ends the input if endInput
// says so and otherwise leaves it open. The tool has to end with status
// 1 and leave the directory empty. A tool that went on reading would
// not end until the input did; one that did not remove the file it was
// writing would leave it there, with what it had written so far; one
// that put that file in place because the input ended would leave the
// named file.
// operation says, as a subprocess writing into a directory of its own
// and reading the input from a pipe. It waits until the tool has begun
// writing the file beside the one it was named, and sends it the
// signal, then ends the input if endInput says so and otherwise leaves
// it open. The tool has to end with status 1 and leave the directory
// empty. A tool that went on reading would not end until the input
// did; one that did not remove the file it was writing would leave it
// there, with what it had written so far; one that put that file in
// place because the input ended would leave the named file.
func interrupted(
t *testing.T, signal os.Signal, operation, input string, endInput bool,
) {
@@ -176,38 +152,12 @@ func interrupted(
name := operation + " " + signal.String()
directory := t.TempDir()
command, producer := writing(
t, directory, input,
os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"),
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], "age", operation,
"-o", filepath.Join(directory, "notes"),
)
require.NoError(t, command.Process.Signal(signal))
if endInput {
require.NoError(t, producer.Close())
}
waitForTool(t, name, command)
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Empty(t, left, name)
}
// writing starts argv, the tool told to write into directory, as a
// subprocess reading the input from a pipe, and returns once the tool
// has begun writing the file beside the one it was named. The pipe is
// left open for the caller to end.
func writing(
t *testing.T, directory, input string, argv ...string,
) (*exec.Cmd, io.WriteCloser) {
t.Helper()
//nolint:gosec // this test's own binary as the tool, or nohup running it
command := exec.CommandContext(t.Context(), argv[0], argv[1:]...)
command.Env = append(os.Environ(), runAsTool+"=1")
producer, err := command.StdinPipe()
@@ -225,7 +175,19 @@ func writing(
return err == nil && len(entries) > 0
}, 5*time.Second, 5*time.Millisecond)
return command, producer
require.NoError(t, command.Process.Signal(signal))
if endInput {
require.NoError(t, producer.Close())
}
waitForTool(t, name, command)
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Empty(t, left, name)
}
// written puts the contents in a file of that name in a directory of
+2 -1
View File
@@ -80,7 +80,8 @@ func Main() int {
return 0
}
if passed, ok := errors.AsType[ssh.StatusError](err); ok {
var passed ssh.StatusError
if errors.As(err, &passed) {
return passed.Status
}
-34
View File
@@ -1,34 +0,0 @@
// Package signals catches the signals that end the tool, for the
// commands that clean up before they end.
package signals
import (
"context"
"os"
"os/signal"
"syscall"
)
// Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the
// context it returns is cancelled when one of them arrives, and stop
// stops catching them. It leaves out any of the three the tool was
// started with set to be ignored, as nohup does with SIGHUP, because
// catching a signal turns an ignored one back on and would end a run
// that was meant to survive it.
func Context(parent context.Context) (context.Context, context.CancelFunc) {
endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP}
caught := make([]os.Signal, 0, len(endings))
for _, ending := range endings {
if !signal.Ignored(ending) {
caught = append(caught, ending)
}
}
// Given no signals at all, NotifyContext would catch every one.
if len(caught) == 0 {
return context.WithCancel(parent)
}
return signal.NotifyContext(parent, caught...)
}
+6 -2
View File
@@ -8,12 +8,13 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"slices"
"strings"
"syscall"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals"
)
// Where the key goes on the host and what the file it arrives in is
@@ -66,7 +67,10 @@ func install() *cobra.Command {
// From here on a signal cancels the context, which
// sftp runs under, instead of ending the tool, so sftp
// ends and the working directory is still removed.
ctx, stop := signals.Context(cmd.Context())
ctx, stop := signal.NotifyContext(
cmd.Context(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
cmd.SetContext(ctx)
+7 -3
View File
@@ -6,11 +6,11 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"slices"
"syscall"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals"
)
// StatusError says the tool should end with the status ssh ended with.
@@ -47,7 +47,10 @@ func to() *cobra.Command {
// cancels the context instead of ending the tool, so
// ssh ends and the socket and its directory are still
// removed.
ctx, stop := signals.Context(cmd.Context())
ctx, stop := signal.NotifyContext(
cmd.Context(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
served, err := key.Serve(ctx, comment)
@@ -96,7 +99,8 @@ func connect(ctx context.Context, argv []string) error {
return nil
}
if ended, ok := errors.AsType[*exec.ExitError](err); ok {
var ended *exec.ExitError
if errors.As(err, &ended) {
status := ended.ExitCode()
if status < 0 {
// A signal ended ssh, and a signal has no status of its