Tighten publicBaseUrl port rewriting (#4553)

## Thinking Path

> - Paperclip is a control plane for autonomous agent companies, so its
local and authenticated deployment behavior has to stay predictable
under port rebinding and worktree isolation.
> - This change sits in the server/worktree configuration path that
derives runtime URLs and auth origins from `auth.publicBaseUrl`.
> - The original hostname-port rewrite change fixed one real gap for
private/tailnet host:port worktree setups, but it widened the rewrite
rule too far.
> - Rewriting every explicit `auth.publicBaseUrl` can corrupt public or
reverse-proxy URLs by turning a stable origin like
`https://paperclip.example` into a local listen-port URL.
> - Paperclip's auth and trusted-origin handling depend on that URL
staying semantically correct, so this had to be narrowed before merge.
> - This pull request tightens the rewrite rule to explicit-port URLs
only and adds regression coverage across the CLI helper, worktree config
persistence, and server startup path.
> - The benefit is that private host:port worktree flows still work,
while public/default-port URLs remain stable and safe.

## What Changed

- Tightened `rewriteLocalUrlPort` in `cli/src/commands/worktree-lib.ts`,
`server/src/worktree-config.ts`, and `server/src/index.ts` so it only
rewrites URLs that already include an explicit port.
- Removed the old loopback-only hostname gate from the CLI/worktree
helpers and replaced it with the more precise `parsed.port` guard.
- Updated CLI helper coverage to assert that explicit-port non-loopback
URLs still rewrite while no-port public URLs stay unchanged.
- Expanded `server/src/__tests__/worktree-config.test.ts` to cover
explicit-port rewrite and no-port stability for both persisted worktree
config and in-memory runtime port selection.
- Added startup-path coverage in
`server/src/__tests__/server-startup-feedback-export.test.ts` for
`detect-port` rebinding with both explicit-port and no-port
`auth.publicBaseUrl` values.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `npx vitest run
server/src/__tests__/server-startup-feedback-export.test.ts`
- `npx vitest run cli/src/__tests__/worktree.test.ts
server/src/__tests__/worktree-config.test.ts`
- All of the above were run locally in this issue worktree and passed.

## Risks

- Low risk. The behavior change is deliberately narrower than the
reviewed broad-host rewrite and is guarded by regression coverage for
both the explicit-port and no-port cases.
- The main remaining risk is behavioral only if another code path starts
depending on port rewriting for URLs that never declared a port, which
would be a separate bug.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex local agent using `gpt-5.4` with high reasoning effort,
tool use, shell execution, and file editing.
- Anthropic Claude local agent using `claude-opus-4-6` for follow-up
code review approval on the implementation issue.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley
2026-04-26 14:29:22 -07:00
committed by GitHub
parent d47ffa87f0
commit 08af830430
6 changed files with 211 additions and 80 deletions
+2 -1
View File
@@ -190,8 +190,9 @@ describe("worktree helpers", () => {
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
});
it("rewrites loopback auth URLs to the new port only", () => {
it("rewrites auth URLs only when they already include a port", () => {
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
expect(rewriteLocalUrlPort("http://my-host.ts.net:3100", 3110)).toBe("http://my-host.ts.net:3110/");
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
});
+2 -6
View File
@@ -75,11 +75,6 @@ function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "127.0.0.1" || value === "localhost" || value === "::1";
}
export function sanitizeWorktreeInstanceId(rawValue: string): string {
const trimmed = rawValue.trim().toLowerCase();
const normalized = trimmed
@@ -168,7 +163,8 @@ export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): s
if (!rawUrl) return undefined;
try {
const parsed = new URL(rawUrl);
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
// The URL API normalizes default ports like :80/:443 to "", so treat them as stable URLs.
if (!parsed.port) return rawUrl;
parsed.port = String(port);
return parsed.toString();
} catch {
@@ -4,6 +4,7 @@ const {
createAppMock,
createDbMock,
detectPortMock,
loadConfigMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
@@ -11,59 +12,7 @@ const {
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
const createDbMock = vi.fn(() => ({}) as never);
const detectPortMock = vi.fn(async (port: number) => port);
const feedbackExportServiceMock = {
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 0, sent: 0, failed: 0 })),
};
const feedbackServiceFactoryMock = vi.fn(() => feedbackExportServiceMock);
const fakeServer = {
once: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
listen: vi.fn((_port: number, _host: string, callback?: () => void) => {
callback?.();
return fakeServer;
}),
close: vi.fn(),
};
return {
createAppMock,
createDbMock,
detectPortMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
};
});
vi.mock("node:http", () => ({
createServer: vi.fn(() => fakeServer),
}));
vi.mock("detect-port", () => ({
default: detectPortMock,
}));
vi.mock("@paperclipai/db", () => ({
createDb: createDbMock,
ensurePostgresDatabase: vi.fn(),
getPostgresDataDirectory: vi.fn(),
inspectMigrations: vi.fn(async () => ({ status: "upToDate" })),
applyPendingMigrations: vi.fn(),
reconcilePendingMigrationHistory: vi.fn(async () => ({ repairedMigrations: [] })),
formatDatabaseBackupResult: vi.fn(() => "ok"),
runDatabaseBackup: vi.fn(),
authUsers: {},
companies: {},
companyMemberships: {},
instanceUserRoles: {},
}));
vi.mock("../app.js", () => ({
createApp: createAppMock,
}));
vi.mock("../config.js", () => ({
loadConfig: vi.fn(() => ({
const loadConfigMock = vi.fn(() => ({
deploymentMode: "authenticated",
deploymentExposure: "private",
bind: "loopback",
@@ -99,7 +48,61 @@ vi.mock("../config.js", () => ({
heartbeatSchedulerEnabled: false,
heartbeatSchedulerIntervalMs: 30000,
companyDeletionEnabled: false,
})),
}));
const feedbackExportServiceMock = {
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 0, sent: 0, failed: 0 })),
};
const feedbackServiceFactoryMock = vi.fn(() => feedbackExportServiceMock);
const fakeServer = {
once: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
listen: vi.fn((_port: number, _host: string, callback?: () => void) => {
callback?.();
return fakeServer;
}),
close: vi.fn(),
};
return {
createAppMock,
createDbMock,
detectPortMock,
loadConfigMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
};
});
vi.mock("node:http", () => ({
createServer: vi.fn(() => fakeServer),
}));
vi.mock("detect-port", () => ({
default: detectPortMock,
}));
vi.mock("@paperclipai/db", () => ({
createDb: createDbMock,
ensurePostgresDatabase: vi.fn(),
getPostgresDataDirectory: vi.fn(),
inspectMigrations: vi.fn(async () => ({ status: "upToDate" })),
applyPendingMigrations: vi.fn(),
reconcilePendingMigrationHistory: vi.fn(async () => ({ repairedMigrations: [] })),
formatDatabaseBackupResult: vi.fn(() => "ok"),
runDatabaseBackup: vi.fn(),
authUsers: {},
companies: {},
companyMemberships: {},
instanceUserRoles: {},
}));
vi.mock("../app.js", () => ({
createApp: createAppMock,
}));
vi.mock("../config.js", () => ({
loadConfig: loadConfigMock,
}));
vi.mock("../middleware/logger.js", () => ({
@@ -216,4 +219,36 @@ describe("startServer PAPERCLIP_API_URL handling", () => {
expect(started.apiUrl).toBe("http://127.0.0.1:3210");
expect(process.env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:3210");
});
it("rewrites explicit-port auth public URLs when detect-port selects a new port", async () => {
loadConfigMock.mockReturnValueOnce({
...loadConfigMock(),
port: 3100,
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://my-host.ts.net:3100",
});
detectPortMock.mockResolvedValueOnce(3110);
const started = await startServer();
expect(started.listenPort).toBe(3110);
expect(started.apiUrl).toBe("http://my-host.ts.net:3110");
expect(process.env.PAPERCLIP_RUNTIME_API_URL).toBe("http://my-host.ts.net:3110");
});
it("keeps no-port auth public URLs stable when detect-port selects a new port", async () => {
loadConfigMock.mockReturnValueOnce({
...loadConfigMock(),
port: 3100,
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://paperclip.example",
});
detectPortMock.mockResolvedValueOnce(3110);
const started = await startServer();
expect(started.listenPort).toBe(3110);
expect(started.apiUrl).toBe("https://paperclip.example");
expect(process.env.PAPERCLIP_RUNTIME_API_URL).toBe("https://paperclip.example");
});
});
+114 -12
View File
@@ -24,7 +24,7 @@ afterEach(() => {
}
});
function buildLegacyConfig(sharedRoot: string) {
function buildLegacyConfig(sharedRoot: string, publicBaseUrl = "http://127.0.0.1:3100") {
return {
$meta: {
version: 1,
@@ -56,7 +56,7 @@ function buildLegacyConfig(sharedRoot: string) {
},
auth: {
baseUrlMode: "explicit" as const,
publicBaseUrl: "http://127.0.0.1:3100",
publicBaseUrl,
disableSignUp: false,
},
storage: {
@@ -439,7 +439,7 @@ describe("worktree config repair", () => {
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
});
it("persists runtime-selected worktree ports back into config", async () => {
it("persists runtime-selected worktree ports back into explicit-port auth URLs", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-"));
const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
@@ -452,7 +452,7 @@ describe("worktree config repair", () => {
configPath,
JSON.stringify(
{
...buildLegacyConfig(instanceRoot),
...buildLegacyConfig(instanceRoot, "http://my-host.ts.net:3100"),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
@@ -518,20 +518,122 @@ describe("worktree config repair", () => {
expect(writtenConfig.server.port).toBe(3103);
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/");
expect(writtenConfig.auth.publicBaseUrl).toBe("http://my-host.ts.net:3103/");
});
it("can update the in-memory config without rewriting env-driven ports", () => {
const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), {
serverPort: 3104,
databasePort: 54340,
allowServerPortWrite: false,
allowDatabasePortWrite: true,
it("does not rewrite no-port public auth URLs when persisting runtime-selected ports", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-public-ports-"));
const worktreeRoot = path.join(tempRoot, "PAP-125-public-base-url");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
const instanceRoot = path.join(isolatedHome, "instances", "pap-125-public-base-url");
await fs.mkdir(paperclipDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
...buildLegacyConfig(instanceRoot, "https://paperclip.example"),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
embeddedPostgresPort: 54331,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(instanceRoot, "data", "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(instanceRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3101,
allowedHostnames: [],
serveUi: true,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(instanceRoot, "data", "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
},
},
},
null,
2,
) + "\n",
"utf8",
);
process.chdir(worktreeRoot);
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-125-public-base-url";
process.env.PAPERCLIP_HOME = isolatedHome;
process.env.PAPERCLIP_INSTANCE_ID = "pap-125-public-base-url";
process.env.PAPERCLIP_CONFIG = configPath;
maybePersistWorktreeRuntimePorts({
serverPort: 3103,
databasePort: 54335,
});
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(writtenConfig.server.port).toBe(3103);
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
expect(writtenConfig.auth.publicBaseUrl).toBe("https://paperclip.example");
});
it("can update the in-memory config when auth URL already includes a port", () => {
const { config, changed } = applyRuntimePortSelectionToConfig(
buildLegacyConfig("/tmp/shared", "http://my-host.ts.net:3100"),
{
serverPort: 3104,
databasePort: 54340,
allowServerPortWrite: false,
allowDatabasePortWrite: true,
},
);
expect(changed).toBe(true);
expect(config.server.port).toBe(3100);
expect(config.database.embeddedPostgresPort).toBe(54340);
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/");
expect(config.auth.publicBaseUrl).toBe("http://my-host.ts.net:3104/");
});
it("does not rewrite the in-memory config when auth URL has no explicit port", () => {
const { config, changed } = applyRuntimePortSelectionToConfig(
buildLegacyConfig("/tmp/shared", "https://paperclip.example"),
{
serverPort: 3104,
databasePort: 54340,
allowServerPortWrite: false,
allowDatabasePortWrite: true,
},
);
expect(changed).toBe(true);
expect(config.server.port).toBe(3100);
expect(config.database.embeddedPostgresPort).toBe(54340);
expect(config.auth.publicBaseUrl).toBe("https://paperclip.example");
});
});
+2 -1
View File
@@ -191,7 +191,8 @@ export async function startServer(): Promise<StartedServer> {
if (!rawUrl) return undefined;
try {
const parsed = new URL(rawUrl);
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
// The URL API normalizes default ports like :80/:443 to "", so treat them as stable URLs.
if (!parsed.port) return rawUrl;
parsed.port = String(port);
return parsed.toString();
} catch {
+2 -6
View File
@@ -27,16 +27,12 @@ function sanitizeWorktreeInstanceId(rawValue: string): string {
return normalized || "worktree";
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "127.0.0.1" || value === "localhost" || value === "::1";
}
function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
if (!rawUrl) return undefined;
try {
const parsed = new URL(rawUrl);
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
// The URL API normalizes default ports like :80/:443 to "", so treat them as stable URLs.
if (!parsed.port) return rawUrl;
parsed.port = String(port);
return parsed.toString();
} catch {