mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
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:
parent
d47ffa87f0
commit
08af830430
6 changed files with 211 additions and 80 deletions
|
|
@ -190,8 +190,9 @@ describe("worktree helpers", () => {
|
||||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
).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://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");
|
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,6 @@ function nonEmpty(value: string | null | undefined): string | null {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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 {
|
export function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||||
const trimmed = rawValue.trim().toLowerCase();
|
const trimmed = rawValue.trim().toLowerCase();
|
||||||
const normalized = trimmed
|
const normalized = trimmed
|
||||||
|
|
@ -168,7 +163,8 @@ export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): s
|
||||||
if (!rawUrl) return undefined;
|
if (!rawUrl) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(rawUrl);
|
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);
|
parsed.port = String(port);
|
||||||
return parsed.toString();
|
return parsed.toString();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const {
|
||||||
createAppMock,
|
createAppMock,
|
||||||
createDbMock,
|
createDbMock,
|
||||||
detectPortMock,
|
detectPortMock,
|
||||||
|
loadConfigMock,
|
||||||
feedbackExportServiceMock,
|
feedbackExportServiceMock,
|
||||||
feedbackServiceFactoryMock,
|
feedbackServiceFactoryMock,
|
||||||
fakeServer,
|
fakeServer,
|
||||||
|
|
@ -11,59 +12,7 @@ const {
|
||||||
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
|
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
|
||||||
const createDbMock = vi.fn(() => ({}) as never);
|
const createDbMock = vi.fn(() => ({}) as never);
|
||||||
const detectPortMock = vi.fn(async (port: number) => port);
|
const detectPortMock = vi.fn(async (port: number) => port);
|
||||||
const feedbackExportServiceMock = {
|
const loadConfigMock = vi.fn(() => ({
|
||||||
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(() => ({
|
|
||||||
deploymentMode: "authenticated",
|
deploymentMode: "authenticated",
|
||||||
deploymentExposure: "private",
|
deploymentExposure: "private",
|
||||||
bind: "loopback",
|
bind: "loopback",
|
||||||
|
|
@ -99,7 +48,61 @@ vi.mock("../config.js", () => ({
|
||||||
heartbeatSchedulerEnabled: false,
|
heartbeatSchedulerEnabled: false,
|
||||||
heartbeatSchedulerIntervalMs: 30000,
|
heartbeatSchedulerIntervalMs: 30000,
|
||||||
companyDeletionEnabled: false,
|
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", () => ({
|
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(started.apiUrl).toBe("http://127.0.0.1:3210");
|
||||||
expect(process.env.PAPERCLIP_API_URL).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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ afterEach(() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildLegacyConfig(sharedRoot: string) {
|
function buildLegacyConfig(sharedRoot: string, publicBaseUrl = "http://127.0.0.1:3100") {
|
||||||
return {
|
return {
|
||||||
$meta: {
|
$meta: {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
@ -56,7 +56,7 @@ function buildLegacyConfig(sharedRoot: string) {
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
baseUrlMode: "explicit" as const,
|
baseUrlMode: "explicit" as const,
|
||||||
publicBaseUrl: "http://127.0.0.1:3100",
|
publicBaseUrl,
|
||||||
disableSignUp: false,
|
disableSignUp: false,
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
|
|
@ -439,7 +439,7 @@ describe("worktree config repair", () => {
|
||||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
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 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 worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
||||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||||
|
|
@ -452,7 +452,7 @@ describe("worktree config repair", () => {
|
||||||
configPath,
|
configPath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
...buildLegacyConfig(instanceRoot),
|
...buildLegacyConfig(instanceRoot, "http://my-host.ts.net:3100"),
|
||||||
database: {
|
database: {
|
||||||
mode: "embedded-postgres",
|
mode: "embedded-postgres",
|
||||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||||
|
|
@ -518,20 +518,122 @@ describe("worktree config repair", () => {
|
||||||
|
|
||||||
expect(writtenConfig.server.port).toBe(3103);
|
expect(writtenConfig.server.port).toBe(3103);
|
||||||
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
|
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", () => {
|
it("does not rewrite no-port public auth URLs when persisting runtime-selected ports", async () => {
|
||||||
const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), {
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-public-ports-"));
|
||||||
serverPort: 3104,
|
const worktreeRoot = path.join(tempRoot, "PAP-125-public-base-url");
|
||||||
databasePort: 54340,
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||||
allowServerPortWrite: false,
|
const configPath = path.join(paperclipDir, "config.json");
|
||||||
allowDatabasePortWrite: true,
|
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(changed).toBe(true);
|
||||||
expect(config.server.port).toBe(3100);
|
expect(config.server.port).toBe(3100);
|
||||||
expect(config.database.embeddedPostgresPort).toBe(54340);
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,8 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
if (!rawUrl) return undefined;
|
if (!rawUrl) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(rawUrl);
|
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);
|
parsed.port = String(port);
|
||||||
return parsed.toString();
|
return parsed.toString();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,12 @@ function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||||
return normalized || "worktree";
|
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 {
|
function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
|
||||||
if (!rawUrl) return undefined;
|
if (!rawUrl) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(rawUrl);
|
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);
|
parsed.port = String(port);
|
||||||
return parsed.toString();
|
return parsed.toString();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue