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
484 changes: 259 additions & 225 deletions cmd/relayfile-cli/main.go

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions cmd/relayfile-cli/program_name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import (
"bytes"
"regexp"
"strings"
"testing"
)

// commandMention matches guidance naming the relayfile binary as a command to
// run — "relayfile mount", "relayfile supervisor install". It deliberately
// requires a space, so the systemd unit name (relayfile-listen.service) and the
// launchd label (com.relayfile.listen) do not match: those are real filenames
// and must stay literal whoever is invoking us.
//
// A bare `relayfile` on its own in a usage block counts too: one such line
// survived a pass that only looked for `relayfile <verb>`.
var commandMention = regexp.MustCompile(`\brelayfile(?: [a-z]|\s*$|\s{2,})`)

// Nouns, not instructions. "delegated relayfile credentials" describes what is
// missing; it tells nobody to run anything.
var allowedNouns = []string{"relayfile credentials", "relayfile workspace id"}

func withoutAllowedNouns(text string) string {
for _, noun := range allowedNouns {
text = strings.ReplaceAll(text, noun, "")
}
return text
}

// TestUsageNamesTheInvokingProgram pins the contract behind relayfile#509:
// mounted as `agent-relay file`, nothing may tell the user to run `relayfile`,
// because that binary is not installed for them.
//
// Checked over the usage printers rather than one message, since the leak was
// never in one place: an earlier fix corrected five call sites found by
// grepping for "run relayfile", and review found dozens more phrased
// differently. A pattern is the only thing that catches the next one.
func TestUsageNamesTheInvokingProgram(t *testing.T) {
t.Setenv(programNameEnv, "agent-relay file")

printers := map[string]func(*bytes.Buffer){
"usage": func(b *bytes.Buffer) { printUsage(b) },
"listen": func(b *bytes.Buffer) { printListenUsage(b) },
"supervisor": func(b *bytes.Buffer) { printSupervisorUsage(b) },
"workspace": func(b *bytes.Buffer) { printWorkspaceUsage(b, "") },
"integration": func(b *bytes.Buffer) {
printIntegrationUsage(b, "")
},
"ops": func(b *bytes.Buffer) { printOpsUsage(b, "") },
"writeback": func(b *bytes.Buffer) { printWritebackUsage(b, "") },
"digest": func(b *bytes.Buffer) { printDigestUsage(b, "") },
}

for name, print := range printers {
t.Run(name, func(t *testing.T) {
var out bytes.Buffer
print(&out)
if found := commandMention.FindString(withoutAllowedNouns(out.String())); found != "" {
t.Errorf("%s usage tells a mounted user to run %q; use programName()", name, found)
}
if !strings.Contains(out.String(), "agent-relay file") {
t.Errorf("%s usage never names the invoking program", name)
}
})
}
}

// TestUsageKeepsServiceFileNames guards the other direction: the systemd unit
// and launchd label are filenames on disk, identical for every caller, and a
// blanket rename would have broken them.
func TestUsageKeepsServiceFileNames(t *testing.T) {
t.Setenv(programNameEnv, "agent-relay file")
var out bytes.Buffer
printSupervisorUsage(&out)
for _, literal := range []string{"relayfile-listen.service", "com.relayfile.listen.plist"} {
if !strings.Contains(out.String(), literal) {
t.Errorf("supervisor usage no longer names %s; that is a real path, not a command", literal)
}
}
}

// TestUsageDefaultsToRelayfile keeps direct users seeing the name they typed.
func TestUsageDefaultsToRelayfile(t *testing.T) {
t.Setenv(programNameEnv, "")
var out bytes.Buffer
printUsage(&out)
if !strings.Contains(out.String(), "relayfile ") {
t.Error("unmounted usage should name relayfile")
}
if strings.Contains(out.String(), "agent-relay file") {
t.Error("unmounted usage must not name the host")
}
}
6 changes: 5 additions & 1 deletion packages/sdk/typescript/src/relay-cli/binary-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ describe("stdout is byte-exact", () => {
const throughSurface = await invoke(realBinDir, argv)
const direct = spawnSync(
path.join(realBinDir, process.platform === "win32" ? "relayfile.exe" : "relayfile"),
argv
argv,
// The surface sets this, so the baseline must too: the comparison is
// about bytes surviving the stdio path, not about how the binary names
// itself, which mounted output deliberately changes (relayfile#509).
{ env: { ...process.env, RELAYFILE_PROGRAM_NAME: "agent-relay file" } }
)

expect(throughSurface.code).toBe(direct.status)
Expand Down
13 changes: 12 additions & 1 deletion packages/sdk/typescript/src/relay-cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ function exitCodeForSignal(signal: NodeJS.Signals): number {
* @param options - Optional overrides for binary lookup, env, and cwd.
* @returns A surface satisfying `@agent-relay/cli-surface`'s `RelayCliSurface`.
*/
/**
* How users reach this binary when it is mounted, for messages that instruct
* them to run something. Matches the group name the host registers.
*/
const MOUNTED_PROGRAM_NAME = "agent-relay file"

export function createRelayCliSurface(
options: CreateRelayCliSurfaceOptions = {}
): RelayCliSurface {
Expand Down Expand Up @@ -268,7 +274,12 @@ export function createRelayCliSurface(
// signal handlers are installed: the host owns them.
const child = spawn(command, childArgs, {
cwd,
env,
// The binary writes messages that tell users to run something. It
// has no way to know it was reached through a host, so left alone it
// says "run relayfile login", naming a binary someone who installed
// `agent-relay` does not have. Telling it how it was invoked keeps
// that advice followable; unset, direct users still see `relayfile`.
env: { ...env, RELAYFILE_PROGRAM_NAME: MOUNTED_PROGRAM_NAME },
stdio: ["inherit", "pipe", "pipe"]
})

Expand Down
16 changes: 15 additions & 1 deletion packages/sdk/typescript/src/relay-cli/mount-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,22 @@ interface Capture {
stderr: string
}

/**
* Env for a direct binary spawn used as the comparison baseline.
*
* RELAYFILE_PROGRAM_NAME matches what the surface sets, because these tests
* assert that argv *routes* identically — not that the binary names itself
* identically. Mounted, it deliberately says `agent-relay file` so its advice
* points at a binary the user actually has (relayfile#509). Without this the
* comparison fails on the one difference the mount is supposed to make.
*/
function childEnv(): NodeJS.ProcessEnv {
return { ...process.env, HOME: home, USERPROFILE: home }
return {
...process.env,
HOME: home,
USERPROFILE: home,
RELAYFILE_PROGRAM_NAME: "agent-relay file"
}
}

async function throughSurface(argv: readonly string[]): Promise<Capture> {
Expand Down
66 changes: 66 additions & 0 deletions packages/sdk/typescript/src/relay-cli/program-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { chmodSync, mkdirSync, writeFileSync } from "node:fs"
import path from "node:path"

import { describe, expect, it } from "vitest"

import { createRelayCliSurface } from "./index.js"
import { temporaryDirectory } from "./testing/build-binary.js"

/**
* The binary writes messages that tell users to run something:
*
* credentials not found at …; run relayfile login --api-key …
*
* Reached through `agent-relay file`, that names a binary the user does not
* have — they installed `agent-relay`. The binary cannot know it was mounted,
* so the surface tells it, and the Go side formats those messages with the
* name it is given (relayfile#509).
*
* Asserted against a real spawn rather than a stubbed one, because the value
* has to survive the env the surface actually builds.
*/
function echoEnvBinDir(): string {
const binDir = path.join(temporaryDirectory("program-name"), "bin")
mkdirSync(binDir, { recursive: true })
const script = path.join(binDir, "echo-env.mjs")
writeFileSync(
script,
`process.stdout.write(process.env.RELAYFILE_PROGRAM_NAME ?? "<unset>")\n`
)
const shim = path.join(binDir, "relayfile")
writeFileSync(
shim,
`#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)}\n`
)
chmodSync(shim, 0o755)
return binDir
}

async function programNameSeenByBinary(
env?: NodeJS.ProcessEnv
): Promise<string> {
const chunks: Buffer[] = []
await createRelayCliSurface({
resolve: { binDirs: [echoEnvBinDir()] },
skipCloudPreflight: true,
...(env ? { env } : {})
}).run(["status"], {
stdout: (chunk) =>
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk)),
stderr: () => {}
})
return Buffer.concat(chunks).toString("utf8")
}

describe("mounted program name", () => {
it("tells the binary it was reached through agent-relay file", async () => {
expect(await programNameSeenByBinary()).toBe("agent-relay file")
})

it("sets it even when the caller supplies its own env", async () => {
// A host passing `env` must not accidentally drop the name and send users
// back to a binary they do not have.
const seen = await programNameSeenByBinary({ PATH: process.env["PATH"] ?? "" })
expect(seen).toBe("agent-relay file")
})
})
4 changes: 3 additions & 1 deletion packages/sdk/typescript/src/relay-cli/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ describe("run", () => {
// rather than trip the unknown-command guard.
const result = await invoke(["--help"])
expect(result.code).toBe(0)
expect(result.stdout).toContain("relayfile is the RelayFile CLI")
// Mounted, the binary names the host rather than itself (relayfile#509),
// so this also proves the program name reached the child.
expect(result.stdout).toContain("agent-relay file is the RelayFile CLI")
})

it("writes only through the injected io", async () => {
Expand Down
Loading