NetBelt 1.3.2 - #7
Merged
Merged
Conversation
A normal, successful update printed "The system cannot find the path specified." right after "cleaning up". It came from the second pass of :release_lock_sweep: the first pass has already removed the lock folder, so set /p <"...\holder.txt" fails to open its input, and cmd reports that failed redirection before the 2>nul on the same command takes effect. Wrap the set /p in parentheses so the trailing 2>nul covers the failed input redirection too. When the file exists the first line is still read as before; the two-pass sweep itself is unchanged. Only lines below :run move, so the landing offsets of old updaters are unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.3.1 saved the caller's PSModulePath with set "CALLER_PSMODULEPATH=!PSModulePath!" before pointing it at the Windows PowerShell 5.1 modules, and put it back right before restarting NetBelt. cmd silently skips a line that is longer than 8191 characters after delayed expansion, so with a long PSModulePath nothing was saved and the put-back line became an empty assignment that removed the variable. Measured on 441ea02: callers with 8000 / 8150 characters kept their value, 8180 / 8200 / 9000 / 20000 lost PSModulePath entirely, and the update still ended with rc=0 and no message. Stop saving and restoring. Only the three powershell calls (hash check and expand, :lock_is_stale, :lock_is_foreign) now see the 5.1 module path, each inside its own setlocal, closed by endlocal or by exit /b. setlocal/endlocal copy the environment without a command line, so the length limit does not apply, and the powershell exit code survives endlocal, so the if errorlevel checks after the expand are unchanged. All changes are below :run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… folder The new exe is renamed to NetBelt.exe.<STAMP>.new inside the unpack folder and then copied with xcopy. xcopy silently skips a source of 260 characters or more and still returns 0. Measured on 441ea02 with a TEMP of 200-210 characters: the bundled files were replaced, the move then looked five times for a staged exe that never arrived and stopped with "the app may still be running", leaving the old exe next to the new bundled files (at 220 characters only 3 of 6 files were new). A deep install folder hits the same limit on the destination side: xcopy failed half way and two bundled files were already replaced. Before the rename, while nothing in the install folder has been written, check the two staged paths (in the unpack folder and in the install folder). The staged name is longer than any file in the release, so these are the longest paths the copy will use. If either is 260 characters or more, say that the path is too long and where, clean up the unpack folder and the apply copy, and stop (the decision for 1.3.2: stop before writing and give the reason). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reclaiming a stale install marker is serialised with the take-over marker, which is itself reclaimable after ten minutes. After :claim_takeover succeeded, a run went on through the staleness re-check, ren, rd and md without ever checking that it still held the take-over marker; only :release_takeover looked at holder.txt. Measured on 441ea02 with four gates, deterministic: A stalls right after taking the take-over marker, which is then aged; B reclaims it and stops after judging the install marker stale, just before its ren; A resumes, does not notice it lost the take-over, reclaims and writes its own marker and reaches the swap; B's ren then grabs A's live marker; D gets the free name with md; B cannot put it back and removes it. A and D both ended with rc=0 and "update complete", and the installed exe was D's, not the one A had approved. Right before the ren, check that holder.txt of the take-over marker is still this run's STAMP (:takeover_is_mine) and give way with "another update is in progress" otherwise; the marker belongs to the other run, so it is left alone. Without a holder.txt there is nothing to compare, so that case proceeds as before, like :release_takeover. A second ten-minute stall between the check and the ren, and a run that stalls while holding the install marker itself, remain known limits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_a_zip_swapped_after_the_check_is_refused only looks at the exit code, the missing "update complete", the untouched exe and the removed apply copy. It kept passing with the mismatch exit changed from 2 to 1 (so the refusal reads "failed to expand the ZIP") and with the first line of the mismatch notice removed, so the decision to tell the user why the update was refused was not covered. Add a separate test that runs the same swap and requires the mismatch notice and the absence of the expand-failure message. It fails on both mutants and passes on the real updater.bat; the existing test is left unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When os.listdir(mibs/) failed (folder being replaced, sync client, an ACL that denies listing), _load_or_update_mib_cache returned the cached mibs as-is without looking at cache_needs_update. A cache written by an older parser, or before custom_mibs.json was corrected, was therefore used again: an old parser's mis-mapping (alarm under the standard system node, 1.3.6.1.2.1.1.99) came back, and a child stayed under the parent OID that custom_mibs.json no longer had, with the load line still saying "(cache used)". The user decided (2026-09-20) that an invalid cache is never used. The listing-failure path now returns nothing from the cache when it is invalid, keeps _mib_cache_used False, and says so in its one notice line (the wording only changes when there is a stale cache to refuse). A valid cache is still used, and the cache file is still not rewritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SMIv1 vendor MIBs declare traps as "name TRAP-TYPE ENTERPRISE parent ... ::= N". A v1 trap arrives with snmpTrapOID = <enterprise>.0.N (RFC 3584; pysnmp puts the same OID), but the extractor only knew TRAP-TYPE as a definition boundary: it never extracted the definition and had no way to turn "::= 7" into ENTERPRISE.0.7. The trap list showed 'acme.0.7' where the same trap written as a SMIv2 NOTIFICATION-TYPE showed 'acme2Alarm'. Extract "name TRAP-TYPE ENTERPRISE parent <body> ::= N" (same name anchor and body limits as the other definitions) and hand it to the resolver as (name, parent, '0.N'). On the real MIB set (1,653 files) this only adds 128 trap names; no existing name changes or disappears. MIB_PARSER_VERSION is raised to 2026-09-26.1 because the extraction rules change; otherwise the mib_cache.json written by 1.3.1 would keep being used and the traps would stay unnamed. This is the single bump for 1.3.2; the later MIB fixes in this release rely on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…same-named parent
The extractor only read "::= { parent index }". A declaration written
with several numbers ({ siteRoot 3 2 }) or as a labelled full path
({ iso(1) org(3) ... enterprises(1) 777 9 }) fell out of the
definitions, so its OID was never decided, and in the borrow round its
children took the same-named node from the built-in table,
custom_mibs.json or another module as their parent. Measured on 1.3.1:
- a vendor MIB that puts its own "system" under its enterprise put
acmeSysName on the standard 1.3.6.1.2.1.1.5 (sysName) with no notice;
- one more link in the chain (system ::= { acmeRoot 1 0 },
sysName ::= { system 5 0 }) still landed under the built-in sysName
(mib-02);
- children of { siteRoot 3 2 } went under another vendor's siteMajor
from custom_mibs.json;
- IANA-MAU-MIB's dot3MauType ::= { mib-2 snmpDot3MauMgt(26) 4 } could
not be read, looked ambiguous next to MAU-MIB, and the 29 children only
IANA defines (dot3MauType10GigBaseCX4 ...) lost the names v1.3.0 gave
them (mib-07b).
Read the whole value instead (_parse_oid_value): several numbers become
(parent, 'a.b'), labelled components keep their number, and a value that
starts from a root label becomes ('', '1.3.6...') which the resolver
treats as an absolute OID. A value with a bare name after the first
position is still left out, as before. Because such a declaration's OID
now comes from its own value, the borrow round is not reached for it.
A declaration whose value really cannot be decided (the first name is
imported from a module that is not in mibs/) is still not replaced by a
same-named custom_mibs.json entry: name and trailing numbers cannot tell
a value the user pinned from another vendor's or a standard node
(user decision 2026-09-20, "leave it unnamed when it cannot be
decided"). Pinning the missing imported name in custom_mibs.json names
the children.
Two existing test files used { aRoot 0 1 } as their example of an
undecidable value. That value is now decided (to A-MIB's own OID), so,
as approved by the user, their example is replaced by a truly
undecidable one (IMPORTS aMissing FROM A-SMI; shared ::= { aMissing 1 });
what they check (no foreign name, give up and report when ambiguous) is
unchanged, and the { aRoot 0 1 } case moves to the new test file as a
positive test.
On the real MIB set (1,653 files) the named OIDs go from 138,722 to
141,206: 2,484 names are added (IEEE 802.1/LLDP, IEC 62439, the MAU
types ...), none changes or disappears, and the "69 definitions left
unresolved" notice is gone. MIB_PARSER_VERSION was already raised for
1.3.2 in the TRAP-TYPE commit, so there is no second bump here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.3.1 anchored definition names to the start of a line (b603dfb) so the matcher can no longer restart inside an upper-case word or at "FROM SNMPv2-TC" and invent names. Real MIBs also start a definition on the line that closes the previous one; Cisco's DS3-MIB.my has "ds3Conformance 1 } ds3Compliances OBJECT / IDENTIFIER ::= { ... }", which is valid ASN.1. Since b603dfb that name reached neither the definitions nor the declared names: ds3Compliances was None and 1.3.6.1.2.1.10.30.14.2 showed as 'ds3Conformance.2' (v1.3.0 named it), and in a MIB written the same way the node's children went unnamed too. Keep the line-head anchor and, per section, insert a newline after a "}" that is directly followed (on the same line) by a definition name and one of the extracted keywords (including TRAP-TYPE, extracted since the SMIv1 trap commit). The only new starting point is that "}", and all-upper-case words are rejected the same way as in _MIB_NAME, so the paths b603dfb closed stay closed. Removing the anchor instead would reopen them and make the extraction regex slower. On the real MIB set this adds exactly one name (ds3Compliances); nothing changes or disappears. Together with the previous commit every name v1.3.0 gave on that set is back (138,624 of 138,624). Extraction time is unchanged within noise. MIB_PARSER_VERSION was already raised for 1.3.2, so caches written by 1.3.1 are rebuilt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation
Reading the whole right-hand side (c18a802) made multi-number and
labeled declarations visible, and 1fea5b4 started extracting SMIv1
TRAP-TYPE. When another module, the built-in table or custom_mibs.json
has the same name, such a declaration became one more candidate for a
module that uses the name without declaring it, and because IMPORTS
are not parsed the last resolved candidate won. A Z-MIB child of foo
imported from Y-MIB landed under another vendor's { xRoot 5 1 }
(1.3.6.1.4.1.1111.5.1.7), a child of system imported from SNMPv2-MIB
left 1.3.6.1.2.1.1.7 for a vendor copy of system, and a child of the
built-in linkDown moved under a vendor linkDown TRAP-TYPE. 1.3.1 named
all three correctly in every order.
A declaration whose value is not a single { parent index } step is now
used for its own module's children as before, but enters the table
that other modules look names up in only when the name has no other
candidate. The give-up notice now also finds a same-named rival that
was resolved but kept out of that table, so an ambiguous parent is
still reported as in 0a2054d. Results on the real MIB collection are
unchanged from 0a2054d in three listing orders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a module declares a parent but its value cannot be read
({ acmeRoot acmeSub 9 }), the borrow round lets a child use the
built-in or custom_mibs.json node of the same name if no other module
declares it. Since c18a802 and 1fea5b4 extract multi-number children
and SMIv1 TRAP-TYPE, those children rode the same borrow: a vendor
{ system 5 1 } was named on the standard 1.3.6.1.2.1.1.5.1 and a
TRAP-TYPE with ENTERPRISE system on 1.3.6.1.2.1.1.0.3. 1.3.1 did not
extract either, so both stayed unnamed.
The borrow now applies only to a single { parent index } child, as in
1.3.1. Multi-number and TRAP-TYPE children of a parent that cannot be
resolved in their own module stay unnamed, following the 2026-09-20
decision not to name what cannot be determined. Children of a parent
the module does not declare (IMPORTS) still resolve. Results on the
real MIB collection are unchanged from 0a2054d in three listing orders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…candidates
108b6b5 kept a declaration whose own value is not a single
{ parent index } step out of the table other modules look names up in
when the name has another candidate. It only looked at the value's own
shape, so a single-step declaration placed under such a value still
entered the table unconditionally. With an ACME-MIB root written as
{ iso(1) org(3) dod(6) internet(1) private(4) enterprises(1) 777 } or
{ enterprises 777 1 } and system ::= { acmeRoot 9 } under it, a Z-MIB
child of system imported from SNMPv2-MIB landed on
1.3.6.1.4.1.777.9.99 in the order A, Z and the standard
1.3.6.1.2.1.1.99 became 'system.99'. 1.3.1 could not resolve that
system at all, so it named zAlarm correctly in every order.
A declaration is now treated as newly resolvable when its own value is
not a single step or its parent is such a declaration, down to every
descendant, and only such declarations are held to the no-other-
candidate rule. Children in the same module resolve as before. Results
on the real MIB collection are unchanged in three listing orders.
The parser version stays at 2026-09-26.1 (raised once in 1fea5b4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.3.1 read only { parent number } and dropped a value with a labeled
component such as { system sn(5) }. Since c18a802 that form is read, but
the resolver judged a single step only by the absence of '.' in the
index, so it was handled like a 1.3.1 declaration. With system declared
by an unreadable value, acmeLabeled ::= { system sn(5) } rode the
borrow round and was named on the standard 1.3.6.1.2.1.1.5 with no
notice, and system ::= { acmeRoot sys(9) } entered the table other
modules look names up in, moving a Z-MIB child of system imported from
SNMPv2-MIB to 1.3.6.1.4.1.777.9.99 in the order A, Z. 1.3.1 and 1.3.0
left acmeLabeled unnamed and named zAlarm correctly in every order.
_parse_oid_value now also reports whether any component was labeled,
and the extractor records such definitions. The resolver counts them as
newly read like multi-number values: they do not borrow and enter the
lookup table only without another candidate. Children in the same
module resolve as before. The real MIB collection has no such value and
its results are unchanged in three listing orders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0a2054d named a definition that starts on the line of the previous
definition's closing brace again (DS3-MIB ds3Compliances), which 1.3.1
dropped. The resolver then handled it like a 1.3.1 declaration, so it
widened the IMPORTS candidates and the borrow round beyond 1.3.1. With
BX-MIB writing "bxRoot ... ::= { enterprises 888 } interfaces ... ::=
{ bxRoot 2 }" on one line, a BZ-MIB child of interfaces imported from
IF-MIB landed on 1.3.6.1.4.1.888.2.50 in the order BX, BZ and the
standard 1.3.6.1.2.1.2.50 became 'interfaces.50' with no notice. A child
{ system 5 } after a brace under an unresolvable system was named on the
standard 1.3.6.1.2.1.1.5. 1.3.1 named bzLeaf correctly in every order
and left the child unnamed (1.3.0 behaved like 21000e0).
The extractor now remembers where it inserted a line break after a
brace and records the definition that starts there, and the resolver
counts it as newly read like a labeled or multi-number value. The
definition itself and its children in the same module keep their names
as in 0a2054d. The rewritten text is identical to the previous re.sub on
all 1,653 files of the real MIB collection, and the collection resolves
to the same names in three listing orders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
21000e0 and 4e907de let only a 1.3.1-style { parent number } child
borrow a same-named built-in or custom_mibs.json node when its own
module declares the parent with an unreadable value. A child resolved
that way still became an ordinary parent in its module, so a
multi-number, labeled, TRAP-TYPE or after-brace child placed under it
was named on a standard OID: with system declared by an unreadable
value and acmeSingle ::= { system 4 } borrowed as in 1.3.1,
{ acmeSingle 6 1 } was named on 1.3.6.1.2.1.1.4.6.1 and a TRAP-TYPE
with ENTERPRISE acmeSingle on 1.3.6.1.2.1.1.4.0.2, with no notice.
1.3.1 did not extract any of them and left them unnamed.
The resolver now remembers declarations resolved through the borrow
round and their descendants, and leaves a newly read child under them
unresolved, following the 2026-09-20 decision not to name what cannot
be determined. 1.3.1-style children under a borrowed node resolve as
before. The real MIB collection resolves to the same names in three
listing orders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The trap receiver registers communities through _clean_communities, which strips surrounding whitespace, so " ops " typed on the Trap tab is registered as "ops". The GET/WALK tab passed community_edit.text() as-is: the same " ops " reached a localhost fake agent as " ops " (with spaces). The same input therefore meant two different communities depending on the tab, and a trailing space picked up when pasting from a document made GET/WALK silently time out. Per the snmp-01 decision (a), _collect_request_params now strips the community before sending, matching the trap side and the host/v3-username fields. Spaces inside the community are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With two fake agents on different localhost ports, WALK and GET results
exported to CSV/JSON/TXT carried no port at all: JSON only had "host"
("127.0.0.1" for both), CSV had "# 対象ホスト:" and TXT "対象ホスト:".
Results taken from different ports of the same host (NAT, non-161
agents) could not be told apart from the files. The panel only passed
the port to the worker and never kept it.
Per the snmp-02 decision (a), the port goes into its own field and the
host value is left as it was: JSON gets "port", CSV a "# 対象ポート: N"
line and TXT a "対象ポート: N" line, also for the default 161. The port
is captured the same way as the host: fixed when the request is
accepted (_request_port), moved to _result_port when results arrive
(completed or cancelled), and read before the save dialog opens so a
result that completes during the dialog cannot relabel the file. The
writers take port as an optional keyword so existing callers keep
their output unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fake agent returning 350 rows at 5 ms each was walked from the panel with the real SNMPWorker (2.24 s). SNMPManager.progress_update fired "100件取得中...", "200件取得中..." and "300件取得中...", but the status label only ever showed "WALK実行中..." and then "完了: 350件". The table stays empty until completion, so there was no way to see how far a long walk had got: set_snmp_manager never connected progress_update and nothing in src/ received it. The panel now connects progress_update to _on_operation_progress, which shows "WALK実行中...(100件取得中)". It only does so while the stop button is shown and enabled, i.e. while running and before stop is pressed, so it never overwrites "停止中…" or the final result. isHidden() is used instead of isVisible() because the latter is false whenever the SNMP panel itself is hidden. Progress and result come from the same worker through queued connections, so progress cannot arrive after the result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Starting the trap receiver with version "v1/v2c" and an empty (or
whitespace-only) community, or "両方" with an empty community and no v3
user, showed "🔵 受信中 (ポート N)" with the receiver thread running but
communities=[] and v3_users=[] registered; no warning, no
error_occurred. v2c traps sent to it on localhost were not accepted
with either "" or "public". _clean_communities drops empty strings and
bind() succeeds even with no credentials, so the panel reported
receiving while nothing could ever arrive. The v3 side already refuses
such a start up front for exactly this reason; v1/v2c had no check.
Per the snmp-x1 decision (A), only configurations that accept nothing
are refused: _trap_community_input_error runs after the v3 check and
warns when the version is not "v3", the community is empty after
stripping (same rule as the core), and there is no v3 user ("v1/v2c",
or "両方" without a v3 username). "両方" with an empty community but a
configured v3 user still starts, since v3 traps can be received. An
empty field is not reinterpreted as "accept any community"; that path
was closed on purpose in the core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Files without broken lines or a BOM were loaded with paramiko's HostKeys.load, which calls check() -> lookup for every line and re-hashes every hashed (|1|) entry read so far. A known_hosts copied from OpenSSH (all hashed) with NetBelt's plain lines appended therefore cost O(plain lines after hashed lines x hashed lines): 400 alternating lines made 20,300 hash_host calls, 2,000 lines took 6.5 s, all while holding the known_hosts lock that other connections and key saves wait on. Files that contain hashed names now go through the existing own loader (load_known_hosts), which hashes each hashed name once for validation and never cross-checks lines. Plain-only files keep the paramiko path, so the existing tests that pin that path are untouched. Files with a bare CR line break also stay on paramiko: the own loader only splits on LF and would silently drop every line after the first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Versions before the port was normalised to an integer passed the port string from config.json straight to paramiko, which saved the key as "[host]:<string>". Only the "[host]:22" spelling was mapped back, so lines such as "[host]:022", "[host]:+22" or "[host]:02202" were ignored: TOFU silently accepted a different key, the password reached the peer, and the new key was appended as the truth. A " 22" port produced "[host]: 22 <type> <key>", a line whose name field is split; it was reported as unreadable but only warned about, so TOFU went ahead too. When no key is stored under the exact name (and, for port 22, no "[host]:22" key, which keeps its current priority), keys of plain "[h]:p" lines whose host matches and whose port normalises to the connection's port are now added in memory under the exact name, for any port. Nothing is written to disk. Unreadable lines are classified with the same rule, and a split "[host]: 22" name is rejoined first, so such a line for this device stops the connection like the existing broken "[host]:22" line does (user decision (a)). Other ports still do not use "[host]:22" or "host" lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xists
paramiko compares known_hosts names as plain strings and re-hashes
hashed names with the spelling it is given, and NetBelt passed the
configured host verbatim. OpenSSH lowercases host names before matching
and writes (and hashes) them in lowercase, so a line copied from OpenSSH
combined with an upper-case host, or a device whose host was edited only
in case, was treated as unknown: TOFU silently accepted a different key
and the password reached the peer ("localhost <A>" with host LOCALHOST,
the hashed form, and "[localhost]:2202" all reproduced this).
The same-endpoint rule now ignores case: plain names are compared in
lowercase and hashed names are re-hashed with both the configured and
the lowercased spelling. As before it is only consulted when no key is
stored under the exact name, and matching keys are added in memory only;
the name saved on first connection keeps the configured spelling, since
lowercasing it would diverge from existing upper-case lines. Unreadable
lines are classified with the same rule, so a broken line whose name
differs only in case stops the connection, as the broken "[host]:22"
line already does. The helper is renamed to _use_other_spelling_keys
because it is no longer only about ports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hand-edited config.json can hold a global macro whose "description"
is a number, boolean, list or object. _on_preset_edit passed it as is to
PresetEditDialog, whose setPlainText raised TypeError ("argument 1 has
unexpected type 'int'"), so the editor never opened and the user only
got the generic unexpected-error notice, with no load warning to point
at the cause. The Tools menu already shows such presets as "Y - 123".
Non-string descriptions are now converted the same way the Tools menu
shows them: falsy values become "" (None and 0 already opened blank),
anything else goes through str(). Saving from the editor stores the
description as a string.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The own loader reads known_hosts as UTF-8, while NetBelt writes it with HostKeys.save in the locale encoding (cp932 on Japanese Windows). Routing every file with a hashed name to the own loader garbled host names saved in cp932, so that device fell back to TOFU and the password reached a server presenting another key. Use the own loader for hashed names only when the file is pure ASCII. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pre-save conflict check re-read known_hosts under the lock but only looked up the exact host name. When a concurrent first connection saved the same device under another spelling (different case, or a legacy port spelling such as [host]:22), the check missed it and the password reached a server presenting another key. Apply the same other-spelling rule used at load time when no exact entry exists on disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f7a8590 kept files with non-ASCII bytes on paramiko's HostKeys.load so that cp932 host names stay readable, which brought the quadratic re-hashing back (2,000 half-hashed lines plus one Japanese line: about 500,000 hash_host calls and 8 seconds under the known_hosts lock). Files with hashed names are now read by paramiko's own HostKeys.load (same encoding and newline handling as before) into a HostKeys subclass whose per-line duplicate check compares names as strings, then moved into the client. Lookups are unchanged; undecodable files still fall back to the byte-level loader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ists e53d27f triaged unreadable known_hosts lines case-insensitively even when an exact readable entry was present, so a broken "SW1.EXAMPLE.COM" line stopped the connection to sw1.example.com although its exact key B would have verified the right device (441ea02 warned and verified). Case is now ignored in the triage only when the reading rule would use other spellings: no key under the exact name (for port 22, none under "[host]:22" either). Exact names, legacy port spellings and split lines still stop the connection as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When no line carries the exact host name, keys saved under another case or a legacy port spelling are used for verification (453c72d, e53d27f). If that key does not match, the rejection only said to delete "the host's line", which does not exist under that spelling. _add_other_spelling_keys now returns the names it borrowed keys from, and the BadHostKey message lists them. The message for an exact line is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When several uploads were queued on the SFTP lock and the device closed the SFTP channel, the first failure folded the connection and the panel showed the dropped notice. The queued transfers then failed with "SFTP connection missing" from their own threads, and those queued error signals could arrive after the disconnected signal. _on_error wrote "Error: ..." unconditionally, so the dropped notice disappeared (4 of 6 runs against a local paramiko server) while the panel still refused every operation. _on_error now shows DROPPED_TEXT in the status line when the current manager is no longer live, the same way _update_file_list treats a late listing. The warning dialog still shows the reason, and failures on a live connection are shown as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The download relied on the native save dialog's overwrite prompt and passed only the target path to download_file, which then guessed the consent from whether the target existed when it was called. A file created by another process after the dialog had seen the name as free was therefore read as "overwrite approved" and replaced by os.replace without any prompt (repro: the dialog stub writes PRECIOUS-LOCAL just before returning; no prompt, download completes, target holds REMOTE). The save dialog now runs with DontConfirmOverwrite and the panel asks itself when the chosen target exists. "No" reopens the save dialog with the chosen name, as the native prompt did (user decision B). The answer is passed as download_file(..., overwrite=...): True replaces, False finalizes with os.rename, which refuses a target created later and keeps the downloaded bytes in the temporary name. Calls without the argument keep the old existence-based guess. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since 41cac56 SSH no longer writes typed or pasted chunks on the GUI thread: it hands them to the per-channel writer and waits at most 0.1 s, and until the write ends the connection counts as unable to take the next chunk. The module docstring of send_backpressure, the docstring of MainWindow._attach_send_backpressure and the comment in InteractiveTerminal._drain_send_queue still said SSH writes in place. Only Telnet does now. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41cac56 started a new writer thread for every hand-over and let it end as soon as its queue was empty, so each keystroke and each 512-character paste chunk paid for a thread start. Against a localhost paramiko server without key exchanges, a keystroke went from 0.04 ms (31b45ee) to 0.22 ms and a 540 KB paste through the terminal from 0.62 s to 1.03 s (2 MB: 2.43 s -> 4.18 s as measured by the reviewer). The writer thread now waits up to _LINGER_SECONDS (1 s) for the next hand-over on a condition that shares the writer's lock, and a hand-over to an idle but waiting thread wakes it instead of starting another. busy() now means "not idle" rather than "a thread exists", so the checks in _send_backlogged are unchanged. stop() wakes a waiting thread so dispose and reconnects end it at once; a thread that times out re-checks for work under the lock before it ends, so nothing handed over at the end of the wait is lost. When no thread can start the writer still runs in place and ends as soon as it is drained. Same measurements after the change: keystroke 0.07-0.09 ms, 540 KB paste 0.62-0.64 s, 2 MB 2.57 s (31b45ee 2.49 s). The reviewer's real key-exchange stress (typing and paste, 12 renegotiations each) still delivers every byte in order with a worst GUI tick gap of 0.109 s, and no writer thread is left after dispose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the delete ran When not elevated, ensure_self_program_allow launches "cmd.exe /c netsh ... delete rule name=all dir=in program=<exe> & netsh ... add rule ..." through ShellExecuteW(runas) and cannot wait for it, so it polls rule_exists(name, program=<exe>) every 0.25 s and returned success on the first hit. If "NetBelt - app inbound (self)" already existed for this exe, the first poll could see that pre-existing allow before the elevated delete ran. With a mocked ShellExecuteW/netsh (no real firewall or UAC), a timeline of "delete after the first poll, add fails" returned (True, 'self-exe allow added (elevated ...)') and the rule was gone afterwards. This is a 1.3.2 regression: 41c9187 dropped the invalid action=block, so the delete now really removes the existing allow. A rule seen on a poll cannot be told apart from the pre-existing one, so return early only when the rule shows up right after a poll that did not see it (the add after the delete has finished). If it stays visible, keep polling to the last check and report success only if it is still there; if it was seen and then stays gone, report failure with the same "existing self-exe rules were deleted" note as the admin path. The number and interval of checks (6 x 0.25 s on the GUI thread) are unchanged. Waiting on the process handle (ShellExecuteExW) was not taken: the existing tests stub only ShellExecuteW, so they would trigger a real UAC prompt and netsh. A delete that lands after the whole check window is still indistinguishable. The 1.3.2 test that pinned exactly one show call now checks that only show calls are made, which was its stated premise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fore the first check On the elevated path the "existing self-exe rules were deleted" note was added only when the allow had been seen on a poll and then stayed gone. netsh takes about 0.05 s here, so the elevated delete -> add normally finishes before the first poll (0.25 s). With a pre-existing "NetBelt - app inbound (self)" for this exe and a failing add (mocked ShellExecuteW/netsh, no real firewall or UAC), every poll saw nothing and the message was only "could not confirm", unlike the admin path. Check once for that allow before launching the elevated cmd.exe, and add the same note when it existed and is not visible at the end. The polls after the launch (6 x 0.25 s) are unchanged; the only added cost is this one read-only show before the UAC prompt. The verdict does not use it. Rules for this exe under other names (e.g. the ones the first Windows prompt creates) are still not seen: listing all inbound rules takes about 0.3 s here. The GUI-wait test now bounds the shows before and after the launch separately (at most 1 before, at most 6 after). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the process The comment said there was no way to wait for the elevated process. There is (ShellExecuteExW with SEE_MASK_NOCLOSEPROCESS); it was not taken because the tests present since 441ea02 stub only the real shell32 ShellExecuteW, so switching would make them trigger a real UAC prompt and netsh. Say that in the code, and call the early-done case "reproduced with a mock" rather than "measured", which is how it was reproduced. Comment only; no behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Write down, next to the polling loop and in its test, what the verdict still cannot tell apart: - a transient show failure (netsh rc != 0) right before a pre-existing allow is seen still returns done before the elevated delete ran, as in 31b45ee; - a first-time add that finishes before the first poll waits to the last poll, like a pre-existing allow that stays visible. The test docstring said a first-time add "returns right away as before", which only holds when the first poll misses it. The pre-launch check is deliberately not used to return early: if that single show fails transiently, a pre-existing allow seen before the delete would be reported as done. A new test pins that (it fails with such a shortcut and passes as is). Comments and tests only; no behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… only The elevated path returned done as soon as a check saw the allow right after a check that did not (seen[-2:] == [False, True]). A transient netsh show failure (rc!=0) on one check makes the same pattern, so the next check could see the pre-existing allow before the elevated delete ran and report done; the delete then removed that allow and a failed add left nothing (reproduced with a mocked ShellExecuteW and netsh). Drop the early break and always decide on the sixth check. The number and interval of checks are unchanged, so the upper bound of the wait on the GUI thread is the same, but every order now waits up to it (the early return saved up to about 1.2 s when the add finished after the first check). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one failed check On the elevated path the note that the existing self-exe rules were deleted was added whenever the allow had been seen at all. When no allow existed before the launch and only the last netsh show failed transiently (rc!=0), the allow the elevated add created was still there, yet the message told the user it was deleted and to add one by hand (reproduced with a mocked ShellExecuteW and netsh). Without a pre-existing allow, add the note only when the allow that was seen stays missing on the last two checks. The verdict is unchanged (still a failure, since the last check did not see the allow), and a pre-existing allow keeps the note because a delete between the fifth and sixth checks followed by a failed add looks the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test only bounds the checks after the launch; it allows one show before the launch, so the whole wait on the GUI thread is one show (about 0.05 s) longer than 31b45ee. Say so in the name and docstring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…irewall fixes The Unreleased section now says that a stalled log shows its state in the recording dialog instead of a window, that SSH no longer freezes when the device starts a key exchange just as keys, a paste or a resize go out, that the self-exe firewall allow rebuilds the program's inbound rules and why a failed rebuild is reported, and that the old block-rule delete never ran. The SFTP diagnostic line now says long lines are cut at 500 characters, which is what the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for UAC When NetBelt is not elevated, the self-exe allow runs netsh through UAC and reports its result without waiting for the elevated process. With an allow already present it can say done before the prompt is accepted, and if the rebuild then fails the allow stays deleted. Waiting for the elevated process without freezing the window is a larger change that cannot be tried here without changing real firewall rules, so it is recorded for 1.3.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recording file is opened line-buffered in text mode, so text without a newline (a device prompt such as 'router# ') stays in Python's buffer when write() returns and only reaches the destination on the following flush(). The log writer thread added the bytes right after write(), so while that flush() was stuck on an unresponsive share the recording dialog showed "8 bytes recorded" with the file still at 0 bytes (seen on 1d9d5c5 with a flush-only stall and with a stall in the raw OS write under a real BufferedWriter). A failed flush also left those bytes counted. 441ea02 counted only after flush() returned, and the changelog says the dialog shows what reached the destination. Keep what write() accepted as a separate pending count and move it into written_bytes only when flush() or close() returns; a failure leaves it out. What is written, its order and how the terminal calls the writer are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r cannot split them test_consecutive_hand_overs_use_one_thread and test_typing_does_not_start_a_thread_per_key assert that exactly one writer thread serves a whole sequence of hand-overs, but they ran with the default _LINGER_SECONDS of 1 s. If the test thread stalls for more than a second between two hand-overs (a busy CI runner), the writer correctly ends after its linger and the next hand-over starts a second thread, so the tests failed with "1 != 2" and blamed the product. Reproduced at 1d9d5c5 by sleeping 1.3 s in the caller before the tenth hand-over: both tests failed with 1 != 2. Both tests now patch _LINGER_SECONDS to 30 s while counting, as test_stop_ends_the_waiting_thread_at_once already does. What they check is unchanged: with the writer ending right after each write they still fail (55 and 21 threads), and with the waiting writer never woken the 0.5 s write wait still fails. With the 1.3 s (and a 3 s) stall injected they now pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…with The module docstring of the window-change key exchange test still described the first version of the fix (6c90f50): a separate per-connection "sender" thread for window-change only. Since 6be6500 the size goes through the same per-channel _ChannelWriter as typed and pasted data, and the GUI thread waits for it up to _WRITE_WAIT_SECONDS. Only the explanation changes: name the shared writer and the wait constant, say the writer (not a sender) is stopped in dispose() and bound to its channel, and replace the after-fix numbers with a re-measurement at 1d9d5c5 using the same 3 s method (0.101 s return, 0.121 s worst tick gap, size delivered 3.001 s later). No assertion changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s no longer see The two tests that count writer threads now patch _LINGER_SECONDS to 30 s so a stalled runner cannot split one sequence of hand-overs across two threads. That also hid the product default from them: with _ChannelWriter._LINGER_SECONDS set to 0.0 at f800d3c (back to 41cac56's thread per hand-over, 1.7x slower pastes) all six tests in the file passed, and no other test reads the value. Add test_default_linger_outlasts_back_to_back_hand_overs, which asserts the default is at least 0.1 s: a hundred times the gap between paste chunks measured in the file's docstring (540 KB in 512-character chunks in 0.62-0.64 s, under 1 ms per chunk). It reads the value itself, so no stall can make it fail. With the default set to 0.0 it fails; with the product as is, the file passes (7 tests). The module docstring now says why the default is checked separately. No product change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_consecutive_hand_overs_use_one_thread and test_typing_does_not_start_a_thread_per_key waited only 0.5 s for each write, window-change and keystroke to be written. 0.5 s was picked to stay under the old 1 s linger, so a waiting writer that was never woken would be caught. The linger in these tests is now 30 s, but the 0.5 s stayed, so a busy runner that stalls the writer thread for more than 0.5 s failed the tests although the product wrote everything. Reproduced at f800d3c by stalling the fake channel's 12th write for 0.7 s: "writing did not finish (10th)" and "keystroke 'g' was not written". Stalling the 11th call (a window-change) or the 21st failed them too. Wait up to 5 s instead (writer.write, send_size and both _pump calls). With the 30 s linger a writer that is never woken still fails them, now after 5 s; a writer that ends after every write still fails the count (55 and 21 threads). With the writer stalled 0.7-4.0 s, the caller stalled 1.3-3.0 s, or _pump stalled 1.3-2.5 s, both tests pass. The module docstring records the change. No product change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d hashes The writer-thread test docstring cited f800d3c twice (and 41cac56 once more) as the place the numbers were measured. Those are detached work commits that are re-applied under new hashes when the round is taken into fix/v1.3.2, so readers could not follow them. Describe the version by what it contained instead. The claims were re-checked: with the counting tests lingering 30 s and no default-linger test, a default linger of 0 still passed all 6 tests; with a 0.5 s write bound, a 0.7 s stall on the 12th fake-channel write failed both counting tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The counting tests already waited up to 5 s for the writer, but the other tests in the file still used 1 s and 2 s bounds, so a stall on a busy runner failed them while the writer behaved correctly: - stop test, 1.3 s stall of the test thread right after stop(): "1.30 not less than 1.0" - a 2.5 s stall of the writer thread just before it ends: the stop, dispose and linger-expiry tests all fail with "thread still alive" - a 3 s stall on the 12th fake-channel write: the wait-runs-out test fails with "hand-over left unwritten (11th)" Raise those bounds to 5 s (join 10 s in the stop test so the elapsed time is reported). The defects they guard still fail: a stop() that does not wake the waiting writer leaves it for the patched 30 s linger, and a lost hand-over is never written. Checked with in-memory mutants (no wake on stop, running flag left set, no wake on hand-over, a thread per hand-over, default linger 0): each fails the same tests as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e ended up with test_ssh_resize_when_a_thread_cannot_start.py still spoke of the window-change "sender" (one thread per write) in a test docstring, although data and sizes now go through the lingering _ChannelWriter. Say "writer" in the test docstring, mark the header as describing the design at 30a3821 (6c90f50 on fix/v1.3.2, same patch id), and add how the current writer falls back: it starts a thread only when none is waiting, and writes in place when it cannot start one. Both tests still fail when that in-place write is removed in memory. Assertions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment on why the elevated self-exe path does not switch to ShellExecuteExW said the tests present since 441ea02 patch only the real shell32 ShellExecuteW, but its parenthesis pointed at tests/test_firewall_self_rule_elevated_verdict.py, which was added in 1.3.2. The 441ea02 tests that patch the real ShellExecuteW are tests/test_firewall_rule_state.py and tests/test_firewall_self_program_match.py; tests/test_firewall_self_program_path.py uses a fake ctypes that only has ShellExecuteW, so it would fail rather than raise a real UAC prompt. Name those files instead. Comment only; no behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le state On the elevated self-exe path the "existing inbound rules were deleted" note is chosen only from the pre-launch check and the six post-launch checks, and a transient show failure reads the same as "no rule". Two orders the round-8 reviewer found still get the note wrong (reproduced with a virtual-clock simulation on 1d9d5c5; no netsh, no UAC): - first-time add, the last two shows fail transiently: observations are pre=absent, checks TTTTFF, the allow is still there, yet the note is added; - the pre-launch show fails transiently, the existing allow is deleted between the fifth and sixth checks and the add fails: observations are pre=absent, checks TTTTTF, the allow is gone, yet no note. Each has a twin order with identical observations where the opposite answer is right: the pre-launch show failing and the allow being deleted between the fourth and fifth checks gives TTTTFF, and a first-time add with only the sixth show failing gives TTTTTF, which test_a_failed_last_check_does_not_claim_a_first_time_rule_was_deleted pins to "no note". No condition on these observations can decide by whether the allow really remains; telling them apart would need more checks (ruled out by test_the_checks_after_the_launch_do_not_wait_longer) or parsing netsh's locale-dependent "no rules match" text. Leave the condition as is and document both limits next to it and in the test module docstring. Comments and docstring only; the simulated verdicts are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-9 reviewer found that the "limits of the note" list added in 9d4c982 leaves out the most common way the "existing inbound rules were deleted" note goes missing: the pre-launch show fails transiently, the elevated delete removes the existing allow and the add fails, all before the first post-launch check (the usual timing). The observations are pre=absent, checks FFFFFF, the allow is gone and the message has no note. Reproduced with the virtual-clock simulation (no netsh, no UAC) on 1d9d5c5 and 9d4c982: 12 of the sweep's 18 fail_no_note cases have this shape, the other 6 are the TTTTTF order already listed. A first-time add whose add fails gives the same observations and must not get the note (test_a_rule_that_never_shows_keeps_the_old_message), so the verdict cannot change. Add it as a third item in the docstring and in the comment next to the condition, say for each item when it happens instead of the ambiguous "the former / the latter", and note that the pre-launch check is also capped at one show by test_the_checks_after_the_launch_do_not_wait_longer. Comments and docstring only; the simulated verdicts are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the transport could not be written (key exchange, TCP stall), the terminal size was only remembered in _size_unsent and a separate watcher announced _size_writable (a queued Qt signal) once the transport recovered. The data path (_write_carry -> _send_backlogged) did not look at the held size, so a keystroke arriving after the transport recovered but before the queued event was processed was handed to the writer first. Measured at 78ef376 against a localhost paramiko server that records how many data bytes had arrived when the window-change came in: - clear_to_send.clear(), set_terminal_size(132, 43), clear_to_send.set(), send_command('x') without processing events: 10/10 runs delivered 'x' before the window-change - 'x' typed while stuck (kept in the carry): the data watcher and the size watcher race when the stall ends; 14/20 runs delivered 'x' first The device could process that input with the old width/height. 441ea02 wrote resize_pty synchronously before the next keystroke (10/10 size first), so this was a 1.3.2 regression from the writer thread. _send_backlogged now also reports "cannot write without waiting" while a size is held. The data stays in the carry and the terminal queue until the size watcher has handed the size to the writer, which writes in hand-over order; the data watcher then emits send_drained and the data follows. The size watcher still looks only at the transport, so a size held during a stall still goes out once when the window is zero (conn-02). The check is a flag read, so the GUI thread does not wait, and a pending writer failure is still reported first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tart 48e8d27 made data wait while a terminal size is held (_size_unsent), so a later keystroke cannot overtake the window-change. When the size watcher's thread could not be started (threading.Thread.start raising RuntimeError), DrainWatcher.check left the unstarted thread as its "watching" mark and raised, and _size_unsent stayed True. No notification ever came, so the keystrokes after it waited silently until the next size change, a later held size was never sent either, and dispose raised on joining the unstarted thread before it closed the channel. Measured at 48e8d27 (a localhost paramiko server, only the GUI-thread start of the size watcher failing during a key exchange): 5/5 runs delivered nothing after 2 s with has_pending_sends() still True. 78ef376 delivered the keystroke, but before the window-change. Handing the size to the writer at once was tried first. It keeps the order, but when the writer cannot start a thread either it writes on the GUI thread, which then waited for the whole key exchange (1.0 s of a 1.0 s exchange, 3/3 runs). Now the failed watcher is dropped and _send_terminal_size is retried by a GUI-thread timer at the watcher's poll interval. Once the transport is writable the size goes to the writer as usual and the held keystrokes follow it. The GUI does not wait: with no thread startable at all, the same run gives 0.0 s, the window-change first, then the keystroke, and has_pending_sends() back to False. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b103c45 retried a held window-change with a GUI-thread timer when the size watcher's thread could not be started. Every failed attempt scheduled a new QTimer.singleShot, and each timer rescheduled itself, so every set_terminal_size during that state added one more 10 ms chain. Measured at b103c45 (fake channel in a key exchange, the GUI-thread start of netbelt-send-drain always failing, K calls of set_terminal_size): K=1 96/s, K=10 981/s, K=100 6,814/s _send_terminal_size runs, until the key exchange ended. Now a flag (_size_retry_pending) keeps at most one retry scheduled; it is cleared when the timer fires (_retry_terminal_size), before retrying. The same measurement gives 93-95/s for K=1, 10, 100 and 300, the last size is still sent once after the key exchange, and the retries stop afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
has_pending_sends also holds the next chunk back while a terminal size waits to be sent, since 374864f made _send_backlogged report that. Say so in its docstring, which listed the other reasons only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DrainWatcher.check stored its thread in _thread before calling start(), so a failed Thread.start (RuntimeError "can't start new thread") left an unstarted thread marked as the running watcher. Later checks saw a watcher and started none, so send_drained never came, and stop() joined the unstarted thread and raised. Only the SSH size side (4b195db) handled the failure; the data side let it escape. Measured at 958475e on a localhost paramiko server with only the netbelt-send-drain start failing: resize during a key exchange, let the transport recover, then type 'x' before the size retry timer. The held size made the data side start a watcher, send_command raised, the size went out but 'x' stayed in _carry and send_drained never fired. From the terminal, 'x' raised and 'y', 'z' stayed queued forever. dispose() then raised "cannot join thread before it is started" before client.close(), leaving the transport and the device session open. Telnet had the same weakness: a send error ("can't start new thread") treated as a disconnect, a paste that silently stopped at 12,288 of 196,554 bytes, a negotiation reply turned into a read error, and a dispose that left the socket open. DrainWatcher.check now drops the thread it could not start before re-raising, so the next check can start one, and stop() only joins a live thread. SSH and Telnet treat the failure as "cannot write yet": the input stays in the carry, send queue and terminal queue, and a single GUI timer (the watcher's interval) re-checks and emits send_drained once writable, trying to start the watcher again each time. Telnet asks for the timer through a signal because its reader thread also writes negotiation replies. The GUI never waits, and a held window-change still goes out before later keystrokes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runner keeps the repository on D: and TEMP on C:. setUp built its failure message with os.path.relpath(TESTS, REPO_ROOT), which raises ValueError across drives, so every check errored on the TEMP copy of tests.yml and test_the_copy_itself_passes failed on CI only. Put the path in the message as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
NetBelt 1.3.2: the bugs deferred from 1.3.1, and the ones found by external review.
The full list of changes is the CHANGELOG section
[1.3.2] - 2026-09-30, which is also the release body. This release also adds a Tests workflow that runs the test suite on the Windows runner for pull requests and pushes to main.🤖 Generated with Claude Code