Skip to content

feat(ssh): integrate SSH terminals and encrypted key relay - #379

Merged
theogravity merged 59 commits into
mainfrom
feat/ssh-integration
Oct 14, 2026
Merged

theogravity merged 59 commits into
mainfrom
feat/ssh-integration

Conversation

@theogravity

@theogravity theogravity commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

SSH terminals now open from the ordinary subshell and workspace forms. Choose the connecting node and destination, then use that node’s keys, selected keys from another accessible node, or a password entered in the terminal. The server can act as either relay endpoint.

This integrates the SSH feature stack as one reviewable unit: #333, #334, #336–#341, and #343–#346. The source branches remain open. It includes the LAN routing changes from #377.

Behavior

  • The connection form enables outgoing SSH on submission when the viewer can configure the selected node. Shared nodes explain who must enable it and poll for readiness. Node settings use one SSH toggle and identity section.
  • Encrypted SSH-agent relays expose only explicitly selected keys and signing operations. Private keys stay on their source node. Both nodes require current launch access; revocation and connection loss close affected relays.
  • Relay destinations reuse existing trust. An unfamiliar destination prompts once in the connection flow, with optional fingerprint details. Cancelling saves no trust. Confirmation binds the account, nodes, resolved destination and exact identity, expires after five minutes, and cannot replace a different saved identity. First-use confirmation does not independently authenticate the presented identity.
  • SSH settings manages saved and recent destinations. Manual host-key entry is removed from the UI; the owner-scoped API remains for explicit recovery. Changed destination and machine identities remain blocked until verified recovery.
  • Passwords are entered only when SSH prompts in the terminal and are never saved. Saved destinations distinguish usernames. Workspace/split context is preserved, shared SSH panes remain view-only for non-owners, and “Set up Subshell here” can enroll the destination.
  • Public documentation includes a connection guide with three real UI screenshots, authentication and trust explanations, node settings, and destination enrollment. SSH settings no longer includes a connection button. Eleven older documentation screenshots and five marketing-site copies were refreshed from the current UI; obsolete captures were removed.
  • Requires node protocol 20. Update the server before the nodes. Migrations 0047–0053 introduce SSH state and remove obsolete grant/approval tables while preserving destinations, trust and audit history. One changeset retains the stack’s server/node major-release intent.

Validation

  • Full workspace build: 35 tasks passed, including mobile and the server SPA.

  • Type checks: 56 tasks passed. Lint, prose, design, licenses and lockfile checks passed.

  • Full unit suites: 54 tasks passed using the repository’s test scripts.

  • SSH browser and integration scenarios passed, including a focused rerun after fixing confirmation/error rendering. Coverage includes Testcontainers password/key login, password retry and non-persistence, saved destinations, shared-pane access, real encrypted relay authentication, unfamiliar destination confirmation, changed-key blocking, revocation and destination enrollment.

  • New route tests cover account isolation, expired/single-use tokens, permission revocation, alias changes, conflicting trust and cookie-only access. Screenshots checked the confirmation dialog and SSH settings at narrow and wide widths.

  • Documentation build and export verification passed for all 119 pages. The new guide was checked at 1440px and 390px, with working screenshot zoom and readable content without JavaScript. A repeatable capture script uses two real nodes and a Testcontainers SSH destination.

The screenshot refresh was checked on 11 documentation pages and the marketing site at 1440px and 390px, including image dimensions, zoom, overflow, and documentation without JavaScript.

Automated runtime tests use temporary accounts, databases, SSH fixtures and isolated ports.

@theogravity theogravity changed the title feat(ssh): integrate SSH terminals, encrypted relay, and guided setup feat(ssh): integrate SSH terminals and encrypted key relay Oct 13, 2026
@@ -16,6 +16,7 @@ export const DATA_COMPONENTS = [
"vapid.json",
"peers.json",
"identities",
"ssh-relay",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR (backup/restore gap). This PR adds ssh-relay to the archive allow-list so the plane's relay identity material is backed up, but the sibling trust file the relay depends on, <dataDir>/ssh-machine-pins.json (pane-runtime machine-pin-store.ts: "The file is <nodeDataDir>/ssh-machine-pins.json (0600)"), is not a DATA_COMPONENTS member. Every comparable trust file is here: node-signing.json, node-encryption.json, and peers.json, which is the channels pin store that the exact same TOFU-plus-quarantine doctrine got.

Failure scenario: an admin restores an instance archive. The relay keypair comes back inside ssh-relay/, the pin store does not. MachinePinStore starts with no pins, and the next relay open TOFU-pins whatever keys the identities table presents, with no challenge and no signal. The byte-equality defense this spec spends §4 on silently resets across a backup/restore cycle, while the identical channels defense (peers.json) survives it. If restore should not carry the store, that asymmetry needs a decision sentence like the archive docs carry elsewhere; if it should (peers.json says it should), the one-word fix is:

Suggested change
"ssh-relay",
"ssh-relay",
"ssh-machine-pins.json",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a gap. On the plane the relay pin store is not a data-dir-root file: localRelayIdentityDir() returns join(SUBSHELL_SERVER_DATA_DIR, "ssh-relay"), and machinePinPath puts ssh-machine-pins.json inside it, so the server's copy lives at ssh-relay/ssh-machine-pins.json, which the already-allowlisted ssh-relay component covers. The backup test at services/backups/tests/backups.test.ts:172-182 round-trips exactly that path. Only agent nodes keep the store at their own data-dir root, and those files are outside the instance archive by design (they're not the plane's data). Leaving the allow-list as is.

@theogravity

Copy link
Copy Markdown
Contributor Author

Review finding (repoint timing): the code now hot-applies a server repoint; the security docs still promise the old timing.

set_server_url used to take effect only at the node's next start. On this branch the running daemon hot-reloads the written config and re-dials on its next heartbeat beat, within about fifteen seconds (apps/node/agent/src/daemon.ts refreshConnectionConfig; the updated wording at apps/server/api/src/api/nodes/set-node-server-url.route.ts line 24 and packages/node-admin/src/components/node-server-url-card.tsx line 40. Inline anchoring on those two files refuses on GitHub's side for this oversized diff, so the finding lands here). What the PR did not update:

  • docs/security.md §6, still on this branch: "It does not restart the agent: the address takes effect on the next start, and choosing when is the operator's."
  • .claude/rules/security-context.md line 96, unchanged: "audited naming the NEW value only; takes effect next start."
  • restartRequired: true stays in the response (now deprecated-but-false by the route's own comment, deferred to a future "coordinated contract wave").

"Choosing when is the operator's" is the stated rationale for the old timing: the route's previous docblock named "a machine that vanished mid-request because it silently re-dialed elsewhere" as the surprise the surface exists to remove. An operator who trusts the threat model and treats a repoint as parked until their maintenance window discloses the node key to the named host fifteen seconds after the click. Per the repo's precedence rule (docs/security.md is authoritative, and a code/doc disagreement is a bug worth fixing rather than a choice to make silently), §6 and the rules summary should be corrected in this same change, with the amendment dated the way other reversals are recorded.

void sweepSshDirs().catch((err: unknown) => {
log(`ssh dir sweep failed: ${err instanceof Error ? err.message : String(err)}`);
});
const sshDirSweepTimer = setInterval(() => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (cleanup leak). sshDirSweepTimer is built beside its two siblings and unref'd like them (line 667), but unlike them it is never torn down: the finally at 1454 clears transferSweepTimer and retentionTimer only. The heartbeat block's own comment (line 397) says the timers are "removed on every exit path"; this one is not.

Failure scenario: daemon() is re-enterable in one process (test seams and the restart fixtures do this), and every exited instance keeps an hourly sweep firing against its stale config.dataDir/ctx.meta - two live sweeps double-deleting ssh dirs off different meta snapshots is only the loud version; the quiet one is just a leaked closure holding the old daemon world alive forever. Prod exit goes through stop()/process exit so the unref keeps that harmless, which is exactly why the inconsistency survives: the file's own doctrine (the maintenance/ssh-enabled memos, the census) is that every armed timer names its disarming line.

Suggested change
const sshDirSweepTimer = setInterval(() => {
const sshDirSweepTimer = setInterval(() => {
// (finally block gains: clearInterval(sshDirSweepTimer); beside the transferSweepTimer line)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b9d87a0: the daemon's finally block now clears sshDirSweepTimer beside the transfer and retention sweeps, so a re-entered daemon no longer leaves an exited instance's hourly sweep firing against a stale config.

void sweepSshDirs().catch((err: unknown) => {
log(`ssh dir sweep failed: ${err instanceof Error ? err.message : String(err)}`);
});
const sshDirSweepTimer = setInterval(() => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (cleanup leak). sshDirSweepTimer is built beside its two siblings and unref'd like them (line 667), but unlike them it is never torn down: the finally at lines 1454-1455 clears transferSweepTimer and retentionTimer only. The file's own doctrine around the heartbeat and sweep timers is that everything armed here is removed on every exit path; this timer is the exception.

Failure scenario: daemon() is re-enterable inside one process (the test seams and restart fixtures do exactly this), and each exited instance keeps its hourly sweep firing against a stale config.dataDir/ctx.meta - overlapping sweeps double-deleting from different meta snapshots is the loud version, the quiet version is a leaked closure pinning the whole old-daemon world forever. Production exit goes through stop()/process exit, and the unref keeps that harmless, which is precisely why the omission stays invisible: the fix is one clearInterval(sshDirSweepTimer); line beside the other two in the finally block.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate of the sibling comment; same fix landed in b9d87a0.

// — an agent that predates M2, or one whose block the grammar refused —
// writes NOTHING: absence is not evidence the machine's keys are gone,
// and only a fresh true report may move the plane's copy.
if (event.runtime?.sshFingerprint) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (efficiency, one change with two costs). Before this PR a heartbeat was {type, ts} and the runtime was collected once at boot (ready) plus after service commands. Now every 15-second beat from every node carries the full runtime block, and on this line the plane rewrites nodes.ssh_fingerprint with a fresh JSON round-trip on every beat that carries one, with no comparison to what the row already holds. On the node side the paired half: apps/node/agent/src/daemon.ts line 1284 fires void refreshRuntime() every beat, which re-runs collectRuntime through queryService, i.e. spawns the host's service-manager query on a 15-second cycle, forever, on every enrolled machine.

Concrete cost: with N nodes this is N DB writes per 15 seconds against SQLite's single writer on top of the existing heartbeat touch, plus N service-manager process spawns per 15 seconds on each machine's fleet, to re-report a value (sshFingerprint) that changes exactly when a pairing is created or repaired, and supervision facts that change when the operator edits the service. One JSON-equality guard here, and a refresh cadence tied to the beats that can actually change a fact (service commands, a re-pair, a config reload), keep the mirror honest without the churn. The heartbeat payload itself also grows into the frame budget relayFrameRefIfOverCap shares with the pane wire.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked against main and this is mostly pre-existing. git diff origin/main...HEAD on daemon.ts shows main's heartbeat already sent {type, ts, ...(runtime ? { runtime } : {})} and already called refreshRuntime() every beat; this PR only merges a cached sshFingerprint into that same runtime field (no new per-beat key read or spawn). And the setSshFingerprint write is in the ready handler (node-ws-handler.ts:897), once per connection and gated on event.runtime?.sshFingerprint; the heartbeat case (1026) only sets conn.agent.runtime in memory and touches lastSeen. So no per-beat SQLite write or per-beat service spawn is introduced here. Leaving as is.

}
}
const rows = await new NodeSharesRepository(db).replaceForNode(gate.row.id, entries, user.id);
await getRelayBroker().closeUnauthorizedForNode(gate.row.id);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (revoke path conflates authorization with liveness). closeUnauthorizedForNode re-authorizes every live session through gateSshNode, whose blocker table answers NODE_OFFLINE, NODE_PROTOCOL_HELD, and NODE_IN_MAINTENANCE alongside the real SSH_GATE_OFF/access questions. The consequence of running the sweep on EVERY shares replacement: an owner edits shares for an unrelated reason (or adds a grantee, which revokes nobody) while one of the machines in a live pairing has a momentary link blip, and its in-flight SSH handshakes are cut with an access-revoked word that says the wrong thing. The pane survives (the ssh child is already authenticated), but the retry is a fresh launch the user did not ask for.

The rule the relay already honors elsewhere is that a revocation is asserted where the audience is addressable exactly and asked otherwise: here, a session should be closed when its user has actually lost the SSH gate on either row (sshEnabled, launch access, shares), and a machine that is merely offline right now cannot host a live frame anyway, so a blip needs no cut at all. Narrowing authorize here to the authorization blockers (dropping offline/held/maintenance from this one decision) keeps the off-switch instant and stops the accidental tears.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. closeUnauthorizedForNode now consults a new authorization-only revoked seam (production: sshAccessRevoked) instead of the full connect gate, so offline/held/maintenance no longer read as a revocation on a shares edit; liveness stays with the link, caps, and pane cuts. Pinned by two tests: a broker-level blip-vs-revocation test and a route-level test that an unchanged shares write with B offline leaves the pairing standing.

* reaching a future audit row through a field that promises to be a
* fingerprint.
*/
const SSH_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (two grammars for one canonical value). The same SHA256:-prefixed digest is validated two ways: here /^SHA256:[A-Za-z0-9_-]{1,128}$/ for selections and rosters, and packages/subshell-protocol/src/node-frames.ts:554 /^SHA256:[A-Za-z0-9_-]{43}$/ (report form) for the runtime/mirror block. A SHA-256 base64url digest is exactly 43 characters; the loose spelling accepts 1- to 128-character values of the same alphabet.

Concrete cost: the launch route's fingerprints array passes isSshFingerprints with a value like SHA256:x or a 120-character paste; it is then refused downstream (roster membership, byte-equality) with the same word an honest mismatch gets, because the grammar never told the caller the SHAPE was wrong. And the two constants invite drift: the 43-form is the only one parseNodeSshFingerprintReport will ever accept, so a future producer has two "canonical" validators to choose between and they disagree about length. This mirrors a doctrine the rest of the relay code already applies to itself (ssh-relay.service.ts: "two carriers of one canonical spelling must not be two implementations of it"), and the fix is that: one exported SSH_FINGERPRINT_RE in the 43 form, imported at both sites. If the loose length range is deliberate for some future digest family, the two constants should say so in a comment naming why the API and the report differ.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. One canonical value, one predicate: ssh-frames.ts now enforces exactly the 43 chars of a SHA-256 base64url digest, and node-frames.ts imports isSshFingerprint from it instead of carrying a second regex. Test fixtures that leaned on the loose bound were updated to real-width digests, plus an explicit short-digest refusal case.

);
const machines: ({ node: Awaited<ReturnType<typeof toNodeView>>; ownerLabel?: string } & Awaited<
ReturnType<typeof sshReadiness>
>)[] = [];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (efficiency, N+1 with a serial walk). findAccessible(user.id) has already resolved the caller's rows; the loop then re-reads every row plus its shares through loadNodeGate, awaits toNodeView (which does its own per-row reads), and awaits sshReadiness(gate), all serially. That is a chain of roughly three round-trips per machine run one after another, and this endpoint is what the SSH wizard's machine step opens on, so its latency is a user's Connect click.

Concrete cost: a fleet of ten machines pays ten serialized gate+view+readiness walks where one batched pass would do (loadNodeGate is over the rows findAccessible already handed back, and sshReadiness mostly reads the live-registry memo and the already-held row). Cheapest honest fix: map the rows concurrently with Promise.all and keep loadNodeGate for the access facts, or extend findAccessible to return the shares it resolved. The shape matches the repo's precedent for exactly this class (GET /api/admin/updates renders its whole node list from batched reads for the same reason).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The two instance-level facts (lockdown, serverAccountEnabled) are now read once via sshPolicyConfig(), and the per-machine gate/view/policy build runs under Promise.all instead of a serial walk, so N machines cost one round of shared facts rather than N serialized chains.

try {
// Atomic with enrollment's compare-and-set: never erase its consumed
// record between a state read and deletion.
await db.deleteFrom("nodeSetupKeys").where("id", "=", keyId).where("usedAt", "is", null).execute();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (revocation races an installer that outlives the poll). revokeUnspentKey is atomic against enrollment's compare-and-set, but the read-check-delete cycle still races the installer across the gap between them, and the exec child keeps running when the plane stops watching: ssh_exec's own doctrine is that timed_out "signals nothing" and the command continues. So: a link blip makes sshExecStatus throw exec-lost-* (or the poll deadline passes), enrolledResult finds the key unspent, revokeUnspentKey deletes it, and seconds later the still-running installer reaches the part that spends it and answers the first refusal word ("setup key was rejected"). The user sees "...No machine was enrolled" for a destination that, a beat later, has a complete Subshell install with no node row, which no surface lists.

The refusal sentence is already wrong at the moment it is printed (enrolledResult is one point-in-time read), so the cheap containment is in the message and the audit: name that the installer may still be running and its key has been retired (the remedy is to re-run setup after the machine settles, or check Nodes), and add a late enrolled follow-up the same way the ready-wait path already upgrades a refusal into connected: false after enrolledResult re-checks. An unspent-key delete-on-refusal is still the right default; what must not ship is a confident past tense about an installer the code knows it stopped watching.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The act now tracks an execMayStillRun flag and only revokes an unspent key where the child is proven gone: the terminal ssh_exec_status answer, or a kick that proved nothing started (offline/refused/unsupported). On the two doubt paths (a lost status call, the plane's poll deadline) the key survives its single-use expiry, so a late installer that finishes enrolls successfully instead of half-provisioning against a deleted row. Two regression tests cover both give-ups.

sshDirSweepTimer was armed beside the transfer and retention sweeps but
the daemon's finally block only cleared those two. daemon() is re-enterable
in one process (test seams, restart fixtures), so each exited instance kept
firing its hourly sweep against a stale config, and the leaked closure pinned
the old world. Prod exit masked it because the timer is unref'd.
Six findings from the #379 code review, each with a test:

- Repoint timing (docs): the agent hot-reloads a `set_server_url` and
  re-dials on its next heartbeat beat (~15s, no restart), but
  docs/security.md and the security rules still said "takes effect next
  start." Updated both to match the shipped behavior.

- Shares revocation conflated authorization with liveness. A node edit
  ran the full connect gate against live relay sessions, so an
  offline/held/maintenance machine read as "access-revoked" and cut
  sessions the shares change said nothing about. closeUnauthorizedForNode
  now consults a new authorization-only `revoked` seam; liveness stays
  with the link, caps, and pane cuts.

- The setup key raced an installer that outlives the poll. The act
  deleted an unspent key on every give-up, including the two paths where
  the exec child may still be running (a lost status call, the plane's
  poll deadline). The key now survives those paths so a late enroll
  succeeds; it is still revoked when the child is proven gone.

- The SSH fingerprint grammar split. ssh-frames.ts accepted 1..128 chars
  where node-frames.ts demanded exactly 43 (a SHA-256 base64url digest).
  One canonical value had two disagreeing validators; unified on the
  strict predicate and node-frames imports it.

- Readiness was an N+1 with a serial walk: it re-fetched the instance
  policy facts and each node's view/gate per row in series. The facts are
  fetched once and the machines built concurrently.

- Backup: the reported ssh-machine-pins.json gap is not real. The plane's
  MachinePinStore lives under ssh-relay/ (already archive-allowlisted and
  round-trip-tested); only agents keep it at their data-dir root, outside
  the instance archive by design.
@theogravity

Copy link
Copy Markdown
Contributor Author

Fixed. docs/security.md §6 and the .claude/rules/security-context.md repoint bullet now state the shipped behavior: the running daemon hot-reloads a set_server_url and re-dials the new address on its next heartbeat beat (~15s, no restart), so the disclosure lands on the click, not a future boot. The restartRequired: true field stays (deprecated, per its own comment) pending the coordinated contract wave that retires it.

theogravity and others added 4 commits October 13, 2026 19:59
The arm that actually runs ssh(1) (a launch carrying cmd.ssh) consulted
neither the node's fail-closed ssh-enabled mirror nor its maintenance
switch, while every other SSH arm gates on both first. It now does: an
absent/corrupt mirror or maintenance-on machine refuses the launch (A/B
relay-open included) instead of spawning outbound ssh with the account's
agent socket exported. gateOpen is one shared helper in ssh-shared.ts now
(was duplicated in ssh-aliases/ssh-relay-exec and inlined at three more
arms), so the mirror's three-state read has exactly one home.

Regression tests: absent mirror refuses an ssh launch by name; maintenance
refuses ssh_relay_open for both roles.
- resolve `IdentityAgent unix:/abs` to the socket it names instead of
  falling through to the env agent (wrong keys on the approved path)
- poisonSession now fails the connection whose request is mid-seal, so a
  forged reply landing in that window tears it down instead of letting the
  request complete against a refused session
- sweepDir unlinks files in place (readdir + unlinkat by name) so the sweep
  is no longer quadratic from per-entry rmdir+mkdir
- the relay-open lifetime is validated before the gate (non-finite JSON
  numbers like 1e999 previously became an undefined deadline that silently
  skipped the expiry timer); the sweep cap is now enforced in the runtime

Co-Authored-By: Claude <noreply@anthropic.com>
sshAccessRevoked neutralized lockdown and liveness through the policy
state, but maintenance is read from the node ROW inside sshPolicy, so a
machine slipping into maintenance still made canConnect false and the
function returned true. A shares write on the OTHER node then cut a
healthy live relay with reason access-revoked (and a misnamed audit row),
the exact "a blip is not a revocation" class this round removes. A
maintenance-only blocker set no longer revokes; a genuine loss (launch
access gone, outgoing SSH off) still does, even alongside maintenance.

Also clear sealedInFlight when close() races a resolving seal, so the
flag keeps its stated "true while a seal is in flight" invariant.

Co-Authored-By: Claude <noreply@anthropic.com>
The "sends the selected key machine" test clicks after renderPanel's fixed
50ms settle, but Start stays disabled until the key-home roster query leaves
isPending (rosterReady). On a loaded CI runner that query is still in flight
at click time, so the click lands on the disabled gate and sends nothing — the
launch body read back undefined and the shard failed green-locally/red-in-CI.
Wait for the button to actually enable before clicking; the body assertion is
unchanged, so a real launch regression still fails the test.

Co-Authored-By: Claude <noreply@anthropic.com>
@theogravity
theogravity merged commit db33a2d into main Oct 14, 2026
21 checks passed
@theogravity
theogravity deleted the feat/ssh-integration branch October 14, 2026 04:36
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 14, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant