dsh-cherry-bridge 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/httpApi.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
2
+ import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
3
3
  import type { DeviceRecord } from './deviceStore.js'
4
4
  import type { DeviceStore } from './deviceStore.js'
5
5
  import type { DeviceState, EffectiveLimits } from './connection.js'
@@ -119,6 +119,14 @@ export interface DeviceSummary {
119
119
  security: Record<string, 'ws' | 'wss'>
120
120
  /** The port each host is dialed with (issue #135); see `DeviceRecord`. */
121
121
  ports: Record<string, number>
122
+ /**
123
+ * The addresses the operator has forgotten (issue #185): not dialed, and not
124
+ * re-learned until `cherry_devices` remembers them (or a PATCH puts one back
125
+ * into `hosts`, which clears its mark). Reported beside the address maps for
126
+ * the same reason they are: it is archive state an operator edits, not a
127
+ * credential.
128
+ */
129
+ forgottenHosts: string[]
122
130
  }
123
131
 
124
132
  /** The `device-state` SSE payload. */
@@ -592,11 +600,17 @@ function parsePatch(body: Record<string, unknown>): DevicePatch {
592
600
  if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
593
601
  return normalizeHost(entry)
594
602
  })
595
- // Checked AFTER normalization: `[::1]` and `::1` collapse to one host. The
596
- // scheme/port maps are keyed by host, so a duplicate has no single entry
597
- // and silently last-wins — an ambiguous record, not an edit.
598
- if (new Set(patch.hosts).size !== patch.hosts.length) {
599
- throw new PatchError('hosts must not contain duplicates')
603
+ // Checked AFTER normalization: `[::1]` and `::1` collapse to one host, and
604
+ // host comparison is CASE-INSENSITIVE everywhere else on this face (the
605
+ // learn merge, the forget/remember matching, the mark clearing), so
606
+ // `NAS.example` and `nas.example` are one address too. The scheme/port maps
607
+ // are keyed by host, so a duplicate has no single entry and silently
608
+ // last-wins — an ambiguous record, not an edit.
609
+ const seen = new Set<string>()
610
+ for (const host of patch.hosts) {
611
+ const key = host.toLowerCase()
612
+ if (seen.has(key)) throw new PatchError('hosts must not contain duplicates')
613
+ seen.add(key)
600
614
  }
601
615
  }
602
616
 
@@ -1017,6 +1031,28 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
1017
1031
  return
1018
1032
  }
1019
1033
 
1034
+ // The two endpoint actions (issue #204) sit UNDER the device resource
1035
+ // because that is what they act on: they are the page's door onto #185's
1036
+ // forget/remember, one action per request, so a "patch AND forget" body
1037
+ // with a contradictory ordering never exists.
1038
+ if (first === 'devices' && match.segments.length === 3 && second !== undefined) {
1039
+ const deviceId = decodeSegment(second)
1040
+ if (deviceId === undefined) {
1041
+ writeError(res, 400, 'invalid-request', 'device id is not valid URL encoding')
1042
+ return
1043
+ }
1044
+ const action = match.segments[2]
1045
+ if (action !== 'forget' && action !== 'remember') {
1046
+ writeError(res, 404, 'not-found', 'unknown route')
1047
+ return
1048
+ }
1049
+ if (method !== 'POST') {
1050
+ writeError(res, 405, 'method-not-allowed', 'POST only')
1051
+ return
1052
+ }
1053
+ return await changeEndpoints(req, res, route, deviceId, action)
1054
+ }
1055
+
1020
1056
  if (first === 'pair' && match.segments.length === 1) {
1021
1057
  if (method !== 'POST') {
1022
1058
  writeError(res, 405, 'method-not-allowed', 'POST only')
@@ -1071,7 +1107,23 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
1071
1107
  }
1072
1108
 
1073
1109
  function listDevices(res: HttpResponseLike, route: RouteContext): void {
1074
- const devices: DeviceSummary[] = route.deps.store.list().map((device) => ({
1110
+ writeJson(
1111
+ res,
1112
+ 200,
1113
+ route.deps.store.list().map((device) => deviceSummary(route, device)),
1114
+ )
1115
+ }
1116
+
1117
+ /**
1118
+ * One device as the browser sees it: the public archive projection, never a
1119
+ * credential.
1120
+ *
1121
+ * ONE builder, because FOUR faces answer this shape — `GET /devices`, the
1122
+ * `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
1123
+ * per face is how a field ends up on one and not the others.
1124
+ */
1125
+ function deviceSummary(route: RouteContext, device: DeviceRecord): DeviceSummary {
1126
+ return {
1075
1127
  deviceId: device.deviceId,
1076
1128
  displayName: device.displayName,
1077
1129
  state: projectState(device, route.deps.connect),
@@ -1080,8 +1132,10 @@ function listDevices(res: HttpResponseLike, route: RouteContext): void {
1080
1132
  port: device.port,
1081
1133
  security: { ...device.security },
1082
1134
  ports: { ...device.ports },
1083
- }))
1084
- writeJson(res, 200, devices)
1135
+ // An empty list is the honest "none", not an absent key: the page renders
1136
+ // the forgotten row only when it is non-empty.
1137
+ forgottenHosts: [...(device.forgottenHosts ?? [])],
1138
+ }
1085
1139
  }
1086
1140
 
1087
1141
  /**
@@ -1407,8 +1461,20 @@ async function patchDevice(
1407
1461
  : patch.hosts === undefined
1408
1462
  ? previousPorts
1409
1463
  : portsFor(patch.hosts, previousPorts, previousPort)
1464
+ // A host the operator puts BACK into `hosts` is dialed again, so its forget
1465
+ // mark must go: `hosts` and `forgottenHosts` are disjoint by contract, and an
1466
+ // explicit list is intent to dial. A PATCH that does not touch `hosts`
1467
+ // (displayName/enabled/port) leaves every mark alone. Case-insensitive, the
1468
+ // same comparison the learn merge uses.
1469
+ const nextForgotten =
1470
+ patch.hosts === undefined || existing.forgottenHosts === undefined
1471
+ ? undefined
1472
+ : existing.forgottenHosts.filter(
1473
+ (host) => !patch.hosts!.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()),
1474
+ )
1410
1475
  const next: DeviceRecord = {
1411
1476
  ...existing,
1477
+ ...(nextForgotten === undefined ? {} : { forgottenHosts: nextForgotten }),
1412
1478
  ...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
1413
1479
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
1414
1480
  // hosts is WHOLE-REPLACE: the patch wins outright, never merges. The
@@ -1473,22 +1539,14 @@ async function patchDevice(
1473
1539
 
1474
1540
  // The response and the SSE frame always report the projection of the record
1475
1541
  // we just wrote: a disabled device is `disabled` regardless of what its
1476
- // connection last said, and an enabled one reports its live state.
1477
- const state = projectState(next, route.deps.connect)
1542
+ // connection last said, and an enabled one reports its live state. ONE
1543
+ // projection feeds both, so the frame and the body cannot disagree.
1544
+ const summary = deviceSummary(route, next)
1478
1545
  if (patch.enabled !== undefined && patch.enabled !== previousEnabled) {
1479
- route.hub.publish('device-state', { deviceId, state })
1546
+ route.hub.publish('device-state', { deviceId, state: summary.state })
1480
1547
  }
1481
1548
 
1482
- writeJson(res, 200, {
1483
- deviceId: next.deviceId,
1484
- displayName: next.displayName,
1485
- state,
1486
- enabled: next.enabled,
1487
- hosts: [...next.hosts],
1488
- port: next.port,
1489
- security: { ...next.security },
1490
- ports: { ...next.ports },
1491
- })
1549
+ writeJson(res, 200, summary)
1492
1550
  }
1493
1551
 
1494
1552
  function warnConnect(deviceId: string): (error: unknown) => void {
@@ -1511,12 +1569,129 @@ function sameHosts(a: readonly string[], b: readonly string[]): boolean {
1511
1569
  return a.length === b.length && a.every((host, index) => host === b[index])
1512
1570
  }
1513
1571
 
1572
+ /**
1573
+ * `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
1574
+ * settings page's door onto #185's endpoint maintenance, which until now only
1575
+ * the `cherry_devices` tool could open.
1576
+ *
1577
+ * One action per request, and the action IS the request: there is no composite
1578
+ * "patch the record AND forget a host" body whose ordering would have to be
1579
+ * defined. Every judgement is the store's — the last-candidate refusal, the
1580
+ * case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
1581
+ * the two doors share ONE set of STORE rules; this route only maps the verdicts
1582
+ * onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
1583
+ * differ on purpose — see `parseEndpointHosts`.)
1584
+ *
1585
+ * EVICTION on a change, exactly like the tool's own arm and a PATCH's address
1586
+ * maintenance, and for a DIFFERENT reason on each action — which is why both
1587
+ * are stated here rather than waved through as "parity":
1588
+ *
1589
+ * - FORGET: a pooled connection CAPTURES its record at construction, so the
1590
+ * socket built before this write would keep dialing the host we just forgot.
1591
+ * (The mark itself is honoured by the store's learn merge, so a reconnect is
1592
+ * about the DIAL LIST, not about suppressing the address.)
1593
+ * - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
1594
+ * stored record. Without the eviction a desktop that stays connected would
1595
+ * not re-handshake for as long as the link holds, so the restored address
1596
+ * would come back only after some later flap — the button would look inert.
1597
+ *
1598
+ * The cost is real and inherited from the tool's arm: an eviction disposes the
1599
+ * subscription layer and closes a healthy socket, so a write can drop an
1600
+ * in-flight command's link. One operator click now reaches that churn; that is
1601
+ * the price of "the archive is the truth" for a live connection.
1602
+ */
1603
+ async function changeEndpoints(
1604
+ req: HttpRequestLike,
1605
+ res: HttpResponseLike,
1606
+ route: RouteContext,
1607
+ deviceId: string,
1608
+ action: 'forget' | 'remember',
1609
+ ): Promise<void> {
1610
+ const existing = route.deps.store.list().find((device) => device.deviceId === deviceId)
1611
+ if (existing === undefined) {
1612
+ writeError(res, 404, 'not-found', `no device ${deviceId}`)
1613
+ return
1614
+ }
1615
+
1616
+ let hosts: string[]
1617
+ try {
1618
+ hosts = parseEndpointHosts(await readJsonBody(req))
1619
+ } catch (error) {
1620
+ writeError(res, 400, 'invalid-request', error instanceof Error ? error.message : String(error))
1621
+ return
1622
+ }
1623
+
1624
+ try {
1625
+ if (action === 'forget') {
1626
+ const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts)
1627
+ if (removed.length > 0 || marked.length > 0) evict(route, deviceId)
1628
+ } else {
1629
+ const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts)
1630
+ if (restored.length > 0) evict(route, deviceId)
1631
+ }
1632
+ } catch (error) {
1633
+ // Quarantine first, and distinctly: it is not the request's fault (the 400s
1634
+ // below) and the browser cannot fix it — the archive is READ-ONLY until a
1635
+ // human moves the bad file aside. A store that was ALREADY quarantined
1636
+ // never reaches this arm: its view is empty, so the `list()` above answered
1637
+ // 404 before any write — the same precedence PATCH and DELETE have. This is
1638
+ // the defence for a store that begins refusing after that read, and it is
1639
+ // what keeps a damaged archive from being reported as a bad request.
1640
+ if (writeQuarantineRefusal(res, route, error)) return
1641
+ // The tool's own code for the same refusal, so the page can translate it by
1642
+ // code rather than by prose.
1643
+ if (error instanceof LastCandidateHostError) {
1644
+ writeError(res, 400, 'last-address', error.message)
1645
+ return
1646
+ }
1647
+ writeError(res, 400, 'invalid-device', error instanceof Error ? error.message : String(error))
1648
+ return
1649
+ }
1650
+
1651
+ // The projection of the record we just wrote, never the object we read before
1652
+ // the write: `REMOVED from a declined call` and `UNCHANGED by a no-op` are
1653
+ // both answered from the archive's current row.
1654
+ const fresh = route.deps.store.list().find((device) => device.deviceId === deviceId)
1655
+ writeJson(res, 200, deviceSummary(route, fresh ?? existing))
1656
+ }
1657
+
1658
+ /**
1659
+ * The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
1660
+ * normalized the same way the PATCH normalizes an address.
1661
+ *
1662
+ * The normalization is not cosmetic: an IPv6 literal is stored bare but a
1663
+ * browser copy-pastes it bracketed, and the store matches by host spelling —
1664
+ * so `[::1]` would be treated as an address the record does not dial and merely
1665
+ * marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
1666
+ * spelling (same `normalizeHost`), and deliberately NOT for its duplicate
1667
+ * refusal: a repeated host in an action body names one host twice, which the
1668
+ * store's own sets collapse, while the PATCH's duplicates would build an
1669
+ * ambiguous record (`hosts`, `security` and `ports` are keyed by host).
1670
+ */
1671
+ function parseEndpointHosts(body: Record<string, unknown>): string[] {
1672
+ const hosts = body['hosts']
1673
+ if (!Array.isArray(hosts) || hosts.length === 0) {
1674
+ throw new PatchError('hosts must be a non-empty array')
1675
+ }
1676
+ return hosts.map((entry) => {
1677
+ if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
1678
+ return normalizeHost(entry)
1679
+ })
1680
+ }
1681
+
1514
1682
  /**
1515
1683
  * The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
1516
1684
  * keeps the scheme it was dialed with, a host the list just gained defaults to
1517
1685
  * `ws`, and hosts the list dropped leave no entry behind. An edit is an
1518
1686
  * address edit, not a scheme edit — re-typing the same host must not silently
1519
1687
  * demote a TLS-proxied desktop to a plain dial.
1688
+ *
1689
+ * "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
1690
+ * learn merge, the forget/remember actions and the mark clearing already use:
1691
+ * `NAS.example` and `nas.example` are one address, so a PATCH that re-types
1692
+ * the case keeps the remembered scheme instead of resetting it to `ws`.
1693
+ * `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
1694
+ * address edit happened at all, and a new spelling IS a change to persist.
1520
1695
  */
1521
1696
  function securityFor(
1522
1697
  hosts: readonly string[],
@@ -1524,12 +1699,42 @@ function securityFor(
1524
1699
  ): Record<string, 'ws' | 'wss'> {
1525
1700
  // `Object.hasOwn`, never `previous[host] ?? 'ws'`: a host named `constructor`
1526
1701
  // (or `__proto__`/`toString`) would otherwise inherit a FUNCTION from
1527
- // Object.prototype and poison the map the store then rejects.
1702
+ // Object.prototype and poison the map the store then rejects. The index is
1703
+ // built from OWN entries only, so it is safe for the same reason.
1704
+ const remembered = indexByHost(previous)
1528
1705
  return Object.fromEntries(
1529
- hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : 'ws']),
1706
+ hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? 'ws']),
1530
1707
  )
1531
1708
  }
1532
1709
 
1710
+ /**
1711
+ * A case-insensitive index over a per-host map (issue #189).
1712
+ *
1713
+ * FIRST spelling wins on a collision. A hand-edited archive CAN hold two
1714
+ * spellings of one host (the store does not reject it, and this face does not
1715
+ * touch `previous`), so callers look their exact spelling up FIRST and consult
1716
+ * this index only as the fallback: a surviving spelling therefore keeps its
1717
+ * OWN entry, and the collision rule only decides what a THIRD spelling
1718
+ * inherits.
1719
+ */
1720
+ function indexByHost<T>(previous: Record<string, T>): Map<string, T> {
1721
+ const index = new Map<string, T>()
1722
+ for (const [host, value] of Object.entries(previous)) {
1723
+ const key = host.toLowerCase()
1724
+ if (!index.has(key)) index.set(key, value)
1725
+ }
1726
+ return index
1727
+ }
1728
+
1729
+ /** The exact spelling's entry, else the case-insensitive one, else nothing. */
1730
+ function exactOrRemembered<T>(
1731
+ previous: Record<string, T>,
1732
+ remembered: Map<string, T>,
1733
+ host: string,
1734
+ ): T | undefined {
1735
+ return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase())
1736
+ }
1737
+
1533
1738
  /**
1534
1739
  * The port map for a host list (issue #135): each named host keeps the port it
1535
1740
  * was dialed with, a host the list just gained defaults to `fallback` (the
@@ -1537,16 +1742,18 @@ function securityFor(
1537
1742
  *
1538
1743
  * `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
1539
1744
  * `port` patch): there is no remembered entry to keep, by design — one port
1540
- * field on this face must move the whole list. Same `Object.hasOwn` rule as
1541
- * `securityFor`: an inherited function is not a remembered port.
1745
+ * field on this face must move the whole list. Same `Object.hasOwn`/
1746
+ * case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
1747
+ * keeps its own port, and an inherited function is never a remembered port.
1542
1748
  */
1543
1749
  function portsFor(
1544
1750
  hosts: readonly string[],
1545
1751
  previous: Record<string, number>,
1546
1752
  fallback: number,
1547
1753
  ): Record<string, number> {
1754
+ const remembered = indexByHost(previous)
1548
1755
  return Object.fromEntries(
1549
- hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : fallback]),
1756
+ hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]),
1550
1757
  )
1551
1758
  }
1552
1759
 
package/src/index.ts CHANGED
@@ -489,6 +489,19 @@ function connectionFor(deviceId: string): DeviceConnection {
489
489
  // Spread-if-present, never `wsOptions: undefined`: the option is optional,
490
490
  // and an absent key is the honest "no override" (Node's trust store).
491
491
  ...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
492
+ // The archive write-back (issue #143). The connection has already merged the
493
+ // addresses into its OWN record — this instance's next reconnect dials them
494
+ // — so the only job here is durability. It goes through
495
+ // `DeviceStore.learnEndpoints`, NOT `upsert(record)`: the store's record
496
+ // carries the token of the PAIRING while the connection's mirrors the live
497
+ // one, and an `upsert` of the connection's copy would put a
498
+ // connection-scoped credential into the archive.
499
+ onAdvertisedEndpoints: (endpoints) => {
500
+ // `record` is THIS connection's clone: its `desktopIdentity` is the pin the
501
+ // answer was validated against, so a re-pair under the same deviceId cannot
502
+ // receive an address learned from the previous pairing.
503
+ state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity)
504
+ },
492
505
  })
493
506
  state.connectionsBuilt += 1
494
507
  connection.onState((deviceState: DeviceState) => {
package/src/tools.ts CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
  import { DeviceUnavailableError, RpcDeadlineExceededError } from './connection.js'
3
3
  import type { DeviceConnection, DeviceState } from './connection.js'
4
4
  import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
5
- import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
5
+ import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
6
6
  import {
7
7
  DEVICE_SETTLE_WAIT_MS,
8
8
  INTERACTIONS_LIST_PAGE_CLAMP,
@@ -111,6 +111,13 @@ export interface CherryDeviceView {
111
111
  enabled: boolean
112
112
  hosts: string[]
113
113
  port: number
114
+ /**
115
+ * The addresses the operator has FORGOTTEN (issue #185): not dialed, and not
116
+ * re-learned from the desktop's advertisements until they are remembered.
117
+ * Always present on a view (empty when nothing was ever forgotten) so a
118
+ * caller never has to distinguish "absent" from "none".
119
+ */
120
+ forgottenHosts: string[]
114
121
  }
115
122
 
116
123
  /** The shared `{device, state}` verdict (spec §6: 设备离线). */
@@ -1216,8 +1223,11 @@ export interface CherryToolsDeps {
1216
1223
  * `DeviceConnection` captures its record at construction, so a pooled
1217
1224
  * instance can never observe an edited address or a flipped `enabled` flag.
1218
1225
  *
1219
- * `cherry_unpair` is the other caller: it evicts (drop + close) and then
1220
- * removes the archive entry, so no live socket outlives its record.
1226
+ * `cherry_unpair` evicts (drop + close) and then removes the archive entry,
1227
+ * so no live socket outlives its record, and `cherry_devices`' `forget` /
1228
+ * `remember` actions evict ON A CHANGE for the same reason: a pooled
1229
+ * connection captured the pre-write record, so a forget that only rewrote the
1230
+ * archive would keep dialing the address (and locally re-learning it).
1221
1231
  */
1222
1232
  evict(deviceId: string): void | Promise<void>
1223
1233
  /**
@@ -1642,6 +1652,59 @@ function optionalString(value: unknown): string | undefined {
1642
1652
  return typeof value === 'string' && value !== '' ? value : undefined
1643
1653
  }
1644
1654
 
1655
+ /**
1656
+ * The `hosts` argument of `cherry_devices`' write actions (issue #185): a
1657
+ * non-empty list of non-empty host names, with no case-variant duplicate.
1658
+ *
1659
+ * Each rule is a state the store would have to interpret: an empty name can
1660
+ * never match a dialed host, and two spellings of one host would make "is this
1661
+ * address forgotten?" depend on which entry a comparison hit first. Values are
1662
+ * TRIMMED here (the model-facing surface is where a stray space is caught);
1663
+ * the archive then stores the spelling it already had for a dialed host.
1664
+ */
1665
+ function hostListArgument(value: unknown): string[] | Record<string, unknown> {
1666
+ if (!Array.isArray(value) || value.length === 0) {
1667
+ return { error: 'invalid-hosts', message: '`hosts` must be a non-empty array of host names' }
1668
+ }
1669
+ const hosts: string[] = []
1670
+ const seen = new Set<string>()
1671
+ for (const entry of value) {
1672
+ if (typeof entry !== 'string' || entry.trim() === '') {
1673
+ return { error: 'invalid-hosts', message: '`hosts` entries must be non-empty strings' }
1674
+ }
1675
+ let host = entry.trim()
1676
+ // The archive stores BARE hosts, and the PATCH path unwraps a bracketed
1677
+ // IPv6 literal (`normalizeHost`). This face must agree: a mark for
1678
+ // `[fd00::1]` would never match the `fd00::1` a dial or an advertisement
1679
+ // carries, so it would be a mark with no effect. The ceiling mirrors the
1680
+ // PATCH path's `HOST_MAX`.
1681
+ if (host.startsWith('[') && host.endsWith(']')) {
1682
+ try {
1683
+ new URL(`http://${host}/`)
1684
+ } catch {
1685
+ return {
1686
+ error: 'invalid-hosts',
1687
+ message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`,
1688
+ }
1689
+ }
1690
+ host = host.slice(1, -1)
1691
+ }
1692
+ if (host.length > 256) {
1693
+ return { error: 'invalid-hosts', message: '`hosts` entry exceeds 256 characters' }
1694
+ }
1695
+ const key = host.toLowerCase()
1696
+ if (seen.has(key)) {
1697
+ return {
1698
+ error: 'invalid-hosts',
1699
+ message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`,
1700
+ }
1701
+ }
1702
+ seen.add(key)
1703
+ hosts.push(host)
1704
+ }
1705
+ return hosts
1706
+ }
1707
+
1645
1708
  /**
1646
1709
  * The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
1647
1710
  * in the exact shape Cherry Studio answers it: a `rejected` receipt whose
@@ -2729,25 +2792,100 @@ export class CherryToolEngine {
2729
2792
  description:
2730
2793
  'List every paired Cherry Studio desktop with its connection state ' +
2731
2794
  '(connected/connecting/offline/disabled/identity-mismatch). An optional `device` ' +
2732
- 'narrows the list to one device (display name prefix or deviceId).',
2795
+ 'narrows the list to one device (display name prefix or deviceId). ' +
2796
+ '`action: "forget"` removes the named `hosts` from that device\'s dial candidates AND ' +
2797
+ 'stops the desktop\'s advertisements from adding them back; `action: "remember"` lifts ' +
2798
+ 'that mark, so the next handshake may learn them again. Both write actions require ' +
2799
+ '`device` and `hosts`.',
2733
2800
  parameters: {
2734
2801
  type: 'object',
2735
2802
  properties: {
2736
2803
  device: { type: 'string', description: 'Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name.' },
2804
+ action: {
2805
+ type: 'string',
2806
+ enum: ['list', 'forget', 'remember'],
2807
+ description:
2808
+ 'What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE ' +
2809
+ "device's forgotten addresses and require `device` and `hosts`.",
2810
+ },
2811
+ hosts: {
2812
+ type: 'array',
2813
+ items: { type: 'string' },
2814
+ description:
2815
+ 'For `forget`/`remember`: the host names to act on, matched case-insensitively. ' +
2816
+ '`forget` refuses to remove the last remaining address.',
2817
+ },
2737
2818
  },
2738
2819
  additionalProperties: false,
2739
2820
  },
2740
2821
  execute: async (args) => {
2822
+ const action = optionalString(args['action']) ?? 'list'
2823
+ if (action !== 'list' && action !== 'forget' && action !== 'remember') {
2824
+ return {
2825
+ error: 'invalid-action',
2826
+ message: '`action` accepts exactly "list", "forget" or "remember"',
2827
+ } satisfies Record<string, unknown>
2828
+ }
2741
2829
  const selector = optionalString(args['device'])
2742
- const quarantined = this.#quarantineVerdict()
2743
- if (quarantined !== undefined) return quarantined
2830
+ if (action === 'list') {
2831
+ const quarantined = this.#quarantineVerdict()
2832
+ if (quarantined !== undefined) return quarantined
2833
+ if (selector === undefined) {
2834
+ const records = this.#deps.store.list()
2835
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
2836
+ }
2837
+ const selected = this.#selectRecords(selector)
2838
+ if (!Array.isArray(selected)) return selected
2839
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
2840
+ }
2744
2841
  if (selector === undefined) {
2745
- const records = this.#deps.store.list()
2746
- return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
2842
+ return {
2843
+ error: 'device-required',
2844
+ message:
2845
+ '`action` "forget"/"remember" needs `device`: a selector that names exactly one device',
2846
+ } satisfies Record<string, unknown>
2747
2847
  }
2848
+ const hosts = hostListArgument(args['hosts'])
2849
+ if (!Array.isArray(hosts)) return hosts
2850
+ const quarantined = this.#quarantineVerdict()
2851
+ if (quarantined !== undefined) return quarantined
2748
2852
  const selected = this.#selectRecords(selector)
2749
2853
  if (!Array.isArray(selected)) return selected
2750
- return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
2854
+ const record = selected[0]!
2855
+ let removed: string[] = []
2856
+ let marked: string[] = []
2857
+ let restored: string[] = []
2858
+ try {
2859
+ if (action === 'forget') {
2860
+ ;({ removed, marked } = this.#deps.store.forgetEndpoints(record.deviceId, hosts))
2861
+ } else {
2862
+ ;({ restored } = this.#deps.store.rememberEndpoints(record.deviceId, hosts))
2863
+ }
2864
+ } catch (error) {
2865
+ if (error instanceof LastCandidateHostError) {
2866
+ return {
2867
+ error: 'last-address',
2868
+ device: record.deviceId,
2869
+ message: error.message,
2870
+ } satisfies Record<string, unknown>
2871
+ }
2872
+ throw error
2873
+ }
2874
+ const changed = removed.length > 0 || marked.length > 0 || restored.length > 0
2875
+ // ADDRESS MAINTENANCE reaches the LIVE connection, exactly like the HTTP
2876
+ // PATCH's eviction: a pooled `DeviceConnection` captures its record at
2877
+ // construction, so an instance built before this write would keep
2878
+ // dialing the forgotten host and locally re-adding it on every
2879
+ // reconnect. Evicting drops that socket and makes the next `connect()`
2880
+ // rebuild from the archive we just wrote.
2881
+ if (changed) await this.#deps.evict(record.deviceId)
2882
+ // The resolved record is STALE (hosts/forgottenHosts moved), so the
2883
+ // view is built from the archive's current row, never from the object
2884
+ // the selector handed us.
2885
+ const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId)
2886
+ if (fresh === undefined) return this.#notFoundVerdict(selector)
2887
+ const device = await this.#deviceView(fresh)
2888
+ return action === 'forget' ? { device, removed, marked } : { device, restored }
2751
2889
  },
2752
2890
  })
2753
2891
  }
@@ -2814,6 +2952,7 @@ export class CherryToolEngine {
2814
2952
  enabled: record.enabled,
2815
2953
  hosts: record.hosts,
2816
2954
  port: record.port,
2955
+ forgottenHosts: record.forgottenHosts ?? [],
2817
2956
  }
2818
2957
  }
2819
2958
 
package/ui/AGENTS.md CHANGED
@@ -18,6 +18,27 @@ with its source. Loaded automatically when a file tool touches `ui/**`.
18
18
  - **HTTP lives in `api.ts`** (same-origin `/cherry-remote/api`); a component
19
19
  never fetches directly. Both seams are injected for tests
20
20
  (`eventSourceFactory`, `scopeClient`), so no test stands up a server.
21
+ - **A run always ends, and the deadline is not a verdict.** The server publishes
22
+ a terminal stage on every outcome, but the stream has no replay, so one lost
23
+ frame would otherwise park the wizard on 「配对进行中」 with the submit button
24
+ disabled and no cancel. The run deadline (`runLifetimeMs`, same injection
25
+ style as `codeLifetimeMs`) restores the form and re-reads the device list;
26
+ its copy points at that list instead of claiming failure, and it keeps
27
+ `claimId` so a terminal frame that arrives late still wins. Arm it on the
28
+ `waiting` phase only — never at mount.
29
+ - **The bundle carries the SOURCE COMMENTS.** `ui/vite.config.ts` sets
30
+ `minify: false`, so the docblocks that hold most of this page's reasoning ship
31
+ inside `lib/client.js`: a comment-only edit in `ui/src/**` changes the
32
+ artifact's bytes and needs the rebuild in the same commit. Two follow-ons from
33
+ that: `artifactFreshness` compares against `git show HEAD:<path>`, so it only
34
+ goes green once the rebuild is COMMITTED — running the full suite before
35
+ committing reports it red for the same reason its sibling case ("neither check
36
+ mutates the committed artifacts") is red. Neither red is a real failure.
37
+ - **A per-host action is INERT while a whole-list editor is open.** The address
38
+ editor's draft is sacred (the card does not reseed it while an editor is
39
+ open), so a 忘记/恢复 chosen under it would be re-sent by the next 保存地址 — and
40
+ that PATCH clears the mark for any host it names. Hence
41
+ `disabled={busy || editor === 'address'}` on both controls, pinned by `#204m`.
21
42
  - **jsdom only.** `tests/unit/ui.test.ts` and `uiScopePanel.test.ts` render the
22
43
  page; a browser-only API needs a fake in the test, not a guard in the page.
23
44