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
15 changes: 15 additions & 0 deletions .github/workflows/private-names.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Private names

on:
pull_request:
push:
branches: [main]

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: No private machine, person, or agent names
run: python3 scripts/check-private-names.py
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ mutation — drop a file at the documented path and `ls`/`peek`/`send`/
# <url> <label> — peer with explicit label

ssh://web1.example.com
ssh://nathan@web2.example.com:2222 prod-web-2
ssh://alex@web2.example.com:2222 prod-web-2
ssh://db1.example.com primary-db

# https://#pk.secret URLs work too (token-URL form from `pty-relay
Expand Down
2 changes: 1 addition & 1 deletion browser/src/session-list-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function formatAge(iso: string | null | undefined, now: number = Date.now
* testability — its branches are non-trivial. */
export function shortenCwd(cwd: string): string {
// Best-effort: we don't get HOME from the daemon, so we hard-code
// the macOS pattern. If we ever need to support /home/<user> we'll
// the macOS pattern. If we ever need to support Linux home directories we'll
// pass the prefix in from the caller.
const home = "/Users/";
if (cwd.startsWith(home)) {
Expand Down
4 changes: 2 additions & 2 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
pty.url = "github:compoundingtech/pty";
pty.url = "github:compoundingtech/pty-original-experiment";
pty.inputs.nixpkgs.follows = "nixpkgs";
};

Expand Down
2 changes: 1 addition & 1 deletion integration/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,7 +1084,7 @@ describe("client approval", () => {
await client.waitForText("approval-works", 5000);

// The client sent a hello message after approval; the token's label
// should have been backfilled from it (e.g. "Silber.local (cli)").
// should have been backfilled from it (e.g. "example.local (cli)").
// Give it a moment to persist.
await new Promise((r) => setTimeout(r, 500));
const afterData = await loadClients(await openStore(relayDir));
Expand Down
8 changes: 4 additions & 4 deletions integration/peers-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe("pty-relay peers subcommand", () => {

describe("peers file — declarative provisioning", () => {
it("ls discovers a peer dropped into the peers file (zero commands run)", () => {
fs.writeFileSync(peersFile, "ssh://nathan@web1.example.com prod-web\n");
fs.writeFileSync(peersFile, "ssh://alex@web1.example.com prod-web\n");
clearCallLog();
const result = runCli(["ls", "--config-dir", configDir, "--json"]);
if (result.status !== 0) {
Expand All @@ -183,9 +183,9 @@ describe("peers file — declarative provisioning", () => {
}>;
expect(parsed).toHaveLength(1);
expect(parsed[0].label).toBe("prod-web");
expect(parsed[0].url).toBe("ssh://nathan@web1.example.com");
expect(parsed[0].url).toBe("ssh://alex@web1.example.com");
expect(parsed[0].sessions.map((s) => s.name)).toEqual(["work"]);
expect(readCallLog()).toContain("nathan@web1.example.com pty list --json");
expect(readCallLog()).toContain("alex@web1.example.com pty list --json");
});

it("supports a mixed ssh + https://#pk.secret peers file", () => {
Expand Down Expand Up @@ -322,7 +322,7 @@ describe("phase-2 ssh wiring — every subcommand routes through ssh", () => {
});

// Fix B: ssh://user@host/session URLs. The pty TUI's attach-remote
// path hands us a URL of this shape (Nathan reported the "No known
// path hands us a URL of this shape (the reported "No known
// host" bug); the "URL is a complete address" mental model has to
// work from either side of the seam.
describe("ssh:// URL with session in the path", () => {
Expand Down
8 changes: 4 additions & 4 deletions integration/ssh-ls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe("ssh:// transport — pty-relay ls", () => {
[
CLI_ENTRY,
"add",
"ssh://nathan@beta",
"ssh://alex@beta",
"--label",
"beta",
"--config-dir",
Expand All @@ -170,12 +170,12 @@ describe("ssh:// transport — pty-relay ls", () => {
console.log("add stdout:", result.stdout);
}
expect(result.status).toBe(0);
expect(result.stdout).toContain("Added beta → ssh://nathan@beta");
expect(result.stdout).toContain("Added beta → ssh://alex@beta");
expect(result.stdout).toContain("ok (pty 1.2.3-fake)");

// The probe used `pty --version`, not `pty list --json`.
const calls = fs.readFileSync(path.join(fakeSshDir, "calls.log"), "utf-8");
expect(calls).toContain("nathan@beta pty --version");
expect(calls).toContain("alex@beta pty --version");

// Now reading back via ls should see the new entry. We need the
// fake to keep responding to `list --json` for the second
Expand All @@ -196,6 +196,6 @@ describe("ssh:// transport — pty-relay ls", () => {
expect(ls.status).toBe(0);
const parsed = JSON.parse(ls.stdout);
expect(parsed[0].label).toBe("beta");
expect(parsed[0].url).toBe("ssh://nathan@beta");
expect(parsed[0].url).toBe("ssh://alex@beta");
}, 30_000);
});
124 changes: 124 additions & 0 deletions scripts/check-private-names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Keep private machine, person, and agent names out of this public repository.

Fails when a tracked file names one of the fleet's machines, a home directory
under /home or /Users, a person/ ID, or an agent/fleet/ ID. Write invented
examples instead: web1.example.com, /home/example, /Users/example,
person/alex, agent/example/worker.

Machine names are listed as SHA-256 digests of the lowercase name, so this
file does not publish the names it keeps out. To add a machine, append the
output of `printf %s NAME | shasum -a 256`.

A line that must stay as it is goes in .private-names-allow: one regular
expression per line, searched in `path:text of the line`. Blank lines and
lines starting with # are ignored.

Run it from anywhere inside the repository: python3 scripts/check-private-names.py
"""

import hashlib
import re
import subprocess
import sys
from pathlib import Path

MACHINE_DIGESTS = {
"a994ffcab684f9e2136b0681c20d2a5ef5f23a5cb7b64ab8934d15a888cc4ea3",
"2c619e6895b513481a07dae6c4b5781282ef1eee7a7f16744fafe7dbbd3e5ca2",
"46b6312339466d3b206325f6f402e1fae56cd65f117c779e3b9259833ffbcdf0",
"9fe430e5274621be518924ed90d2355f39eeb1d4d98fb00ea0022f3a26ba5595",
"5881d66b8738b483578c8479c59f09856a5447b47bbd2660cff7dc92e1607a2d",
"294aa8d75483b8331e3ba6a7f24aea15202747f36de65197e7bc6194880b2558",
}

ALLOW_FILE = ".private-names-allow"

WORD = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
HOME = re.compile(r"/(?:home|Users)/([A-Za-z0-9._-]+)")
PERSON = re.compile(r"(?<![A-Za-z0-9_-])person/([A-Za-z0-9._-]+)")
AGENT = re.compile(r"(?<![A-Za-z0-9_-])agent/fleet/")
INVENTED_PEOPLE = {"alex"}


def invented(name):
return name.lower().startswith("example")


def digest(word):
return hashlib.sha256(word.encode()).hexdigest()


def names_a_machine(line):
for word in WORD.findall(line.lower()):
for part in {word, *word.split("-")}:
if digest(part) in MACHINE_DIGESTS:
return True
return False


def problems(line):
if names_a_machine(line):
yield "names a private machine"
for match in HOME.finditer(line):
if not invented(match.group(1)):
yield f"home path {match.group(0)!r}: use /home/example or /Users/example"
for match in PERSON.finditer(line):
name = match.group(1)
if not invented(name) and name.lower() not in INVENTED_PEOPLE:
yield f"person ID {match.group(0)!r}: use person/alex"
if AGENT.search(line):
yield "agent/fleet/ ID: use agent/example/..."


def main():
root = Path(
subprocess.run(
["git", "rev-parse", "--show-toplevel"],
check=True, capture_output=True, text=True,
).stdout.strip()
)
script = Path(__file__).resolve()
this = script.relative_to(root).as_posix() if script.is_relative_to(root) else None
allow_path = root / ALLOW_FILE
allowed = []
if allow_path.exists():
for raw in allow_path.read_text().splitlines():
if raw.strip() and not raw.startswith("#"):
allowed.append(re.compile(raw))

files = subprocess.run(
["git", "ls-files", "-z"],
cwd=root, check=True, capture_output=True,
).stdout.decode().split("\0")

found = 0
for path in files:
if not path or path in (this, ALLOW_FILE):
continue
full = root / path
if not full.is_file():
continue
data = full.read_bytes()
if b"\0" in data:
continue
for number, line in enumerate(data.decode("utf-8", "replace").splitlines(), 1):
located = f"{path}:{line}"
if any(pattern.search(located) for pattern in allowed):
continue
for problem in problems(line):
print(f"{path}:{number}: {problem}")
found += 1

if found:
print(
f"\n{found} private name(s) found. Replace each with an invented "
f"example, or add a pattern to {ALLOW_FILE} if the line must stay.",
file=sys.stderr,
)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
2 changes: 1 addition & 1 deletion src/relay/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface DaemonRuntime {
/** Set when `--tailscale` was passed AND tailscale serve registration
* succeeded. Otherwise undefined. */
tailscale?: {
/** Tailnet hostname, e.g. `silber.pancake-hake.ts.net`. */
/** Tailnet hostname, e.g. `example-host.example-tailnet.ts.net`. */
hostname: string;
/** Almost always 443 for the operator's tailnet serve config; left
* explicit so future non-443 setups slot in. */
Expand Down
8 changes: 4 additions & 4 deletions test/daemon-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,23 +116,23 @@ describe("buildExternalTokenUrl", () => {
const runtime: DaemonRuntime = {
port: 8099,
bind: "127.0.0.1",
tailscale: { hostname: "silber.ts.net", port: 443, scheme: "https" },
tailscale: { hostname: "example.ts.net", port: 443, scheme: "https" },
startedAt: 0,
};
const url = buildExternalTokenUrl(runtime, pk, secret, fakeCreateToken);
// Default https port → no port in the URL.
expect(url).toBe("proto://silber.ts.net#fakepk.fakesecret");
expect(url).toBe("proto://example.ts.net#fakepk.fakesecret");
});

it("includes the tailscale port when it's non-default", () => {
const runtime: DaemonRuntime = {
port: 8099,
bind: "127.0.0.1",
tailscale: { hostname: "silber.ts.net", port: 8443, scheme: "https" },
tailscale: { hostname: "example.ts.net", port: 8443, scheme: "https" },
startedAt: 0,
};
const url = buildExternalTokenUrl(runtime, pk, secret, fakeCreateToken);
expect(url).toBe("proto://silber.ts.net:8443#fakepk.fakesecret");
expect(url).toBe("proto://example.ts.net:8443#fakepk.fakesecret");
});

it("falls back to localhost:<port> when no tailscale", () => {
Expand Down
4 changes: 2 additions & 2 deletions test/local-status-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { probeDaemon } from "../src/commands/local/status.ts";
* Unit tests for `probeDaemon`'s dual-signal (PID alive + TCP port
* listening). The full CLI shape of `pty-relay local status` is
* exercised by the integration suite; here we just pin the probe's
* logic since it's the load-bearing fix for Nathan's "stale pid
* logic since it's the load-bearing fix for the "stale pid
* 12839 (process not found)" reading-it-wrong bug.
*/

Expand Down Expand Up @@ -75,7 +75,7 @@ describe("probeDaemon", () => {
expect(probe.portListening).toBe(true);
});

it("running:false, portListening:false when nothing's around (Nathan's clean-shutdown case)", async () => {
it("running:false, portListening:false when nothing's around (the clean-shutdown case)", async () => {
const port = await pickUnusedPort();
const probe = await probeDaemon(dir, port);
expect(probe).toEqual({
Expand Down
10 changes: 5 additions & 5 deletions test/peers-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("parsePeersFile — line grammar", () => {

it("auto-label strips user@ but keeps the bare hostname", () => {
const hosts = parsePeersFile(
"ssh://nathan@beta.host\n",
"ssh://alex@beta.host\n",
"/test/peers",
);
expect(hosts[0].label).toBe("beta.host");
Expand Down Expand Up @@ -181,22 +181,22 @@ describe("peersFileCandidates", () => {
it("PTY_RELAY_PEERS_FILE is checked first", () => {
process.env.PTY_RELAY_PEERS_FILE = "/explicit/path";
process.env.XDG_CONFIG_HOME = "/xdg";
process.env.HOME = "/home/u";
process.env.HOME = "/home/example";
const list = peersFileCandidates();
expect(list[0]).toBe("/explicit/path");
});

it("XDG_CONFIG_HOME comes second when set", () => {
process.env.XDG_CONFIG_HOME = "/xdg";
process.env.HOME = "/home/u";
process.env.HOME = "/home/example";
const list = peersFileCandidates();
expect(list).toContain("/xdg/pty-relay/peers");
});

it("falls back to ~/.config/pty-relay/peers when XDG isn't set", () => {
process.env.HOME = "/home/u";
process.env.HOME = "/home/example";
const list = peersFileCandidates();
expect(list).toContain("/home/u/.config/pty-relay/peers");
expect(list).toContain("/home/example/.config/pty-relay/peers");
});
});

Expand Down
8 changes: 4 additions & 4 deletions test/session-list-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ afterEach(() => {

describe("shortenCwd", () => {
it("rewrites paths under /Users/<name>/ to ~/...", () => {
expect(shortenCwd("/Users/myobie/src/foo")).toBe("~/src/foo");
expect(shortenCwd("/Users/example/src/foo")).toBe("~/src/foo");
});

it("truncates absolute paths outside /Users with ellipsis + last 2 segments", () => {
Expand Down Expand Up @@ -138,7 +138,7 @@ describe("structure", () => {
it("each row has all four columns with the right contents", () => {
const c = mountContainer();
const v = makeView(c);
v.update([{ name: "s", command: "bash", cwd: "/Users/me/x", tags: { env: "dev" } }]);
v.update([{ name: "s", command: "bash", cwd: "/Users/example/x", tags: { env: "dev" } }]);
const row = c.querySelector(".session-rows .session-row");
expect(row?.querySelector(".col-name")?.textContent).toBe("s");
expect(row?.querySelector(".col-cmd")?.textContent).toBe("bash");
Expand All @@ -162,7 +162,7 @@ describe("structure", () => {
{
name: "<img src=x onerror=alert(1)>",
command: "<script>alert(2)</script>",
cwd: "/Users/me/<x>",
cwd: "/Users/example/<x>",
tags: { "<a>": "<b>" },
},
]);
Expand Down Expand Up @@ -193,7 +193,7 @@ describe("filter", () => {
const v = makeView(c);
v.update([
{ name: "a", command: "claude --resume" },
{ name: "b", cwd: "/Users/me/important-thing" },
{ name: "b", cwd: "/Users/example/important-thing" },
{ name: "c", tags: { project: "the-thing" } },
]);
setFilter(c, "thing");
Expand Down
Loading