Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ func (a *app) guide() {
p("")
p(" Share a terminal (guests, time-boxed):")
p(" " + b + " share <machine> invite someone in — read-only, expires in an hour")
p(" " + b + " share ls your invites; revoke one: " + b + " share revoke <id>")
p(" " + b + " join <code> claim an invite someone sent you")
p("")
p(" Identity & machines:")
Expand Down
22 changes: 22 additions & 0 deletions go/internal/cli/client_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ func (a *app) cmdList(args []string) error {
_ = fs.Parse(args)
// Cheap, non-blocking update notice (cache-only display; refresh in background).
updateClient(a.binary).MaybeNotify(a.errOut, updateCachePath(*dir), version.Version, 24*time.Hour)
client.SweepGuestState(*dir, time.Now()) // shares whose window closed age out here
local, err := client.ListMachines(*dir)
if err != nil {
return err
Expand Down Expand Up @@ -221,6 +222,16 @@ func (a *app) cmdList(args []string) error {
return nil
}
for _, m := range merged {
if m.Owner != "" {
// A share someone gave this identity: the grant, not the registry,
// says what it is and how long it lasts.
detail := "shared with you"
if g := client.GuestGrantFor(*dir, m.MachineID); g != nil {
detail = fmt.Sprintf("shared with you · %s · %s", modeWord(g.Mode), expiryPhrase(g.NA, false, time.Now()))
}
fmt.Fprintf(a.out, "%-16s %s %s\n", m.Name, m.MachineID, detail)
continue
}
tag := ""
if discoveredID[m.MachineID] {
tag = " (online)"
Expand Down Expand Up @@ -634,6 +645,17 @@ func (a *app) cmdAttach(args []string) error {
if err != nil {
return err
}
// A share is checked against its own clock before dialing: an expired grant
// would only earn the agent's silent refusal, which reads as "offline".
for _, m := range resolved {
if m.Owner == "" {
continue
}
g := client.GuestGrantFor(*dir, m.MachineID)
if g == nil || g.ValidAt(time.Now()) != nil {
return fmt.Errorf("your share of %q has ended — ask the owner for a new invite", m.Name)
}
}
iceList := servers
if len(resolved) > 0 && !iceHasTURN(servers) {
if warm.ICEErr == nil && sameRelay(warm.ICEFrom, resolved[0].SignalURL) {
Expand Down
84 changes: 82 additions & 2 deletions go/internal/cli/overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (

"github.com/srcful/terminal-relay/go/internal/client"
"github.com/srcful/terminal-relay/go/internal/defaults"
"github.com/srcful/terminal-relay/go/internal/identity"
"github.com/srcful/terminal-relay/go/internal/noise"
"github.com/srcful/terminal-relay/go/internal/peer"
)
Expand Down Expand Up @@ -76,6 +77,8 @@ func (a *app) cmdOverview() error {
dir: dir,
idn: idn,
pump: pump,
fd: fd,
raw: oldState,
model: &overviewModel{
Binary: a.binary,
Status: "loading your machines…",
Expand Down Expand Up @@ -130,6 +133,8 @@ type overviewState struct {
dir string
idn *client.Identity
pump *stdinPump
fd int // stdin fd, for suspending raw mode around the share ceremony
raw *term.State // the pre-overview terminal state to restore
model *overviewModel
machines []client.Machine // row i -> machines[i]
fresh map[string]bool // machine ids first seen while this overview is up
Expand Down Expand Up @@ -198,13 +203,23 @@ func (ov *overviewState) refresh(ctx context.Context, first bool) bool {
}
rows := make([]overviewRow, 0, len(merged))
for _, m := range merged {
rows = append(rows, overviewRow{
row := overviewRow{
Name: m.Name,
MachineID: m.MachineID,
Online: online[m.MachineID],
New: ov.fresh[m.MachineID],
WindowsLine: ov.windows[m.Name],
})
}
if m.Owner != "" {
// A share someone gave this identity: mark it and let the grant
// speak for its state — the registry never knows it.
row.Shared = true
row.WindowsLine = "shared with you"
if g := client.GuestGrantFor(ov.dir, m.MachineID); g != nil {
row.WindowsLine = fmt.Sprintf("shared with you · %s · %s", modeWord(g.Mode), expiryPhrase(g.NA, false, time.Now()))
Comment on lines +218 to +219

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check shared grants before overview attach

When a guest opens the overview after a grant expires and presses Enter on this shared row, the overview's attach path bypasses the expiry check added to cmdAttach. It therefore enters the reconnect loop and retries the agent's refusal for roughly the full failure budget instead of immediately explaining that the share ended; validate GuestGrantFor(...).ValidAt(...) before making the row attachable.

Useful? React with 👍 / 👎.

}
}
rows = append(rows, row)
}
changed := first || !rowsEqual(ov.model.Rows, rows)
ov.machines = merged
Expand Down Expand Up @@ -266,15 +281,33 @@ func (ov *overviewState) handleKey(ctx context.Context, ev keyEvent) (done bool,
return false, err
}
}
case ovShare:
if row, ok := ov.model.Selected(); ok {
if row.Shared {
ov.model.Status = "that's a share — only its owner can share it onward"
break
}
if i := ov.model.Cursor; i >= 0 && i < len(ov.machines) {
ov.share(ctx, ov.machines[i])
}
}
case ovRename:
if row, ok := ov.model.Selected(); ok {
if row.Shared {
ov.model.Status = "that's a share — only the owner can rename it; it expires on its own"
break
}
ov.prompt = promptRename
ov.input = nil
ov.model.Prompt = "new name for " + row.Name + ": "
ov.model.Input = ""
}
case ovRetire:
if row, ok := ov.model.Selected(); ok {
if row.Shared {
ov.model.Status = "that's a share — it expires on its own; nothing to retire"
break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rename prompt drops the letter s

Medium Severity

handlePromptKey never treats ovShare as typed input, so the new s binding is swallowed inside the rename prompt. Any name that contains s is stored without those letters.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7b4fdd2. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Overview attach skips expired-share check

Medium Severity

cmdAttach now refuses an expired guest grant before dialing so the agent’s silent drop is not mistaken for “offline”. Overview Enter still calls client.Attach with no ValidAt check, so the default mir picker keeps showing that misleading failure.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7b4fdd2. Configure here.

ov.prompt = promptRetire
ov.input = nil
ov.model.Prompt = "Retire " + row.Name + "? It disappears from every device; the machine and its tmux keep running; `" +
Expand Down Expand Up @@ -557,3 +590,50 @@ func (f *detachFilter) Read(p []byte) (int, error) {
f.buf = out
}
}

// pumpReader adapts the overview's stdin pump to a plain io.Reader for the
// share ceremony's line prompts (the terminal is back in cooked mode there, so
// the kernel line-buffers and each Read hands over a full line).
type pumpReader struct {
pump *stdinPump
buf []byte
}

func (r *pumpReader) Read(p []byte) (int, error) {
if len(r.buf) == 0 {
chunk, ok := <-r.pump.ch
if !ok {
return 0, io.EOF
}
r.buf = chunk
}
n := copy(p, r.buf)
r.buf = r.buf[n:]
return n, nil
}

// share runs the mint ceremony for the selected machine: leave the alt screen
// and raw mode (the ceremony prints a QR and asks questions), run it with the
// share defaults (read-only, 1 h, session main — flags need the command form),
// then come back to the overview.
func (ov *overviewState) share(ctx context.Context, m client.Machine) {
a := ov.app
fmt.Fprint(a.out, altScreenOff)
_ = term.Restore(ov.fd, ov.raw)

sa := *a
sa.in = &pumpReader{pump: ov.pump}
err := sa.shareResolved(ctx, ov.dir, ov.idn, m, identity.GrantDefaultTTL, false, "main", defaults.WebURL(), ov.ice())

if _, rerr := term.MakeRaw(ov.fd); rerr != nil && err == nil {
err = rerr
}
fmt.Fprint(a.out, altScreenOn)
switch {
case err != nil:
ov.model.Status = err.Error()
default:
ov.model.Status = "shared — `" + a.binary + " share ls` lists it"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Overview share reports success on decline

Medium Severity

Overview share() treats a nil return from shareResolved as a completed mint. Canceling at the safety-number prompt also returns nil, so the status bar claims the machine was shared when nothing was granted.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7b4fdd2. Configure here.

ov.draw()
}
13 changes: 10 additions & 3 deletions go/internal/cli/overview_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
ovQuit
ovHelp
ovEsc
ovShare
ovRune // an ordinary byte; Rune carries it (prompt input)
)

Expand Down Expand Up @@ -77,6 +78,8 @@ func (d *keyDecoder) feed(b byte) keyEvent {
return keyEvent{Key: ovRename, Rune: b}
case 'x':
return keyEvent{Key: ovRetire, Rune: b}
case 's':
return keyEvent{Key: ovShare, Rune: b}
case '?':
return keyEvent{Key: ovHelp, Rune: b}
}
Expand All @@ -89,7 +92,8 @@ type overviewRow struct {
MachineID string
Online bool
New bool // discovered for the first time while this overview is up
WindowsLine string // dim one-line tmux summary; "" hides the line
Shared bool // a share someone gave this identity (guest entry)
WindowsLine string // dim one-line tmux summary or share detail; "" hides the line
}

// overviewModel is everything the overview renders. The loop mutates it and
Expand All @@ -109,8 +113,8 @@ const (
ansiDim = "\x1b[2m"
ansiBold = "\x1b[1m"
ansiReset = "\x1b[0m"
ovHintBar = "enter attach · r rename · x retire · q quit · ? help"
ovHelpLine = "↑/↓ or j/k move · enter attaches · r renames · x retires (asks first) · q quits"
ovHintBar = "enter attach · s share · r rename · x retire · q quit · ? help"
ovHelpLine = "↑/↓ or j/k move · enter attaches · s shares (owner) · r renames · x retires (asks first) · q quits"
)

// MoveCursor moves the selection, clamped to the row list.
Expand Down Expand Up @@ -162,6 +166,9 @@ func (m *overviewModel) Render() string {
if r.Online {
state = "●"
}
if r.Shared {
state = "⇢" // a share: its grant, not the registry, is its state
}
badge := ""
if r.New {
badge = " " + ansiBold + "NEW" + ansiReset
Expand Down
Loading
Loading