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
43 changes: 42 additions & 1 deletion src/cli/commands/__tests__/auth-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ mock.module('../../utils/server-manager', () => ({
restartServer: mock(async () => {}),
}));

const { authCommand } = await import('../auth');
const { authCommand, normalizeProviderHost } = await import('../auth');

function isolateHome(): { restore: () => void } {
const home = mkdtempSync(join(tmpdir(), 'capa-auth-home-'));
Expand Down Expand Up @@ -142,6 +142,25 @@ describe('authCommand', () => {
exitSpy.mockRestore();
});

describe('normalizeProviderHost', () => {
it('accepts DNS hosts, localhost, IPs, and optional ports', () => {
expect(normalizeProviderHost('github.com')).toBe('github.com');
expect(normalizeProviderHost('git.corp.com:8443')).toBe('git.corp.com:8443');
expect(normalizeProviderHost('localhost')).toBe('localhost');
expect(normalizeProviderHost('localhost:3000')).toBe('localhost:3000');
expect(normalizeProviderHost('192.168.1.10')).toBe('192.168.1.10');
expect(normalizeProviderHost('192.168.1.10:8080')).toBe('192.168.1.10:8080');
expect(normalizeProviderHost('::1')).toBe('[::1]');
expect(normalizeProviderHost('[::1]:8443')).toBe('[::1]:8443');
});

it('rejects schemes and path segments', () => {
expect(normalizeProviderHost('https://github.com')).toBeNull();
expect(normalizeProviderHost('github.com/org')).toBeNull();
expect(normalizeProviderHost('')).toBeNull();
});
});

describe('access-token path', () => {
const originalFetch = globalThis.fetch;
let fetchOk = true;
Expand Down Expand Up @@ -203,6 +222,28 @@ describe('authCommand', () => {
}
});

it('stores a self-hosted token for localhost with a port', async () => {
const { stdout } = await captureOutput(() =>
authCommand('localhost:8443', {
accessToken: 'ghe_local',
type: 'github-enterprise',
}),
);

expect(stdout).toContain('Authenticated with localhost:8443 using access token');

const { loadSettings, getDatabasePath } = await import('../../../shared/config');
const { CapaDatabase } = await import('../../../db/database');
const settings = await loadSettings();
const db = new CapaDatabase(getDatabasePath(settings));
try {
const stored = db.getGitIntegration('github-enterprise', 'localhost:8443');
expect(stored?.access_token).toBe('ghe_local');
} finally {
db.close();
}
});

it('requires --type for unknown self-hosted hosts', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as typeof process.exit);
const { stdout } = await captureOutput(() =>
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/add-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Exported for unit tests.
*/

import { basename, resolve, relative, join } from 'path';
import { basename, resolve, relative } from 'path';
import { access } from 'fs/promises';
import { constants } from 'fs';
import { CANONICAL_HOOK_EVENTS, type CanonicalHookEvent, type Hook, type HookSource } from '../../types/hooks';
Expand Down
63 changes: 59 additions & 4 deletions src/cli/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isIP } from 'net';
import { loadSettings, getDatabasePath } from '../../shared/config';
import { CapaDatabase } from '../../db/database';
import { ensureServer } from '../utils/server-manager';
Expand Down Expand Up @@ -240,9 +241,12 @@ async function authenticateWithAccessToken(
return;
}

if (!isValidProvider(provider)) {
const normalizedHost = normalizeProviderHost(provider);
if (!normalizedHost) {
error(`Invalid provider: ${provider}`);
error('Provider must be a domain name (e.g., github.com, gitlab.com)');
error(
'Provider must be a host (e.g., github.com, localhost:8443, 192.168.1.10). Do not include https://',
);
db.close();
process.exit(1);
return;
Expand All @@ -252,7 +256,7 @@ async function authenticateWithAccessToken(
let host: string | undefined;

try {
({ platform, host } = resolveTokenAuthTarget(provider, options.type));
({ platform, host } = resolveTokenAuthTarget(normalizedHost, options.type));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
error(message);
Expand All @@ -261,7 +265,7 @@ async function authenticateWithAccessToken(
return;
}

const displayHost = host ?? provider;
const displayHost = host ?? normalizedHost;
info(`Authenticating with access token: ${displayHost}`);

try {
Expand Down Expand Up @@ -407,6 +411,57 @@ function isValidProvider(provider: string): boolean {
return /^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}$/i.test(provider);
}

/**
* Normalize a PAT/self-hosted host argument to `host` or `host:port`.
* Accepts DNS names, localhost, IPv4/IPv6 literals, and optional ports.
* Rejects schemes and path segments. Returns null when invalid.
*/
export function normalizeProviderHost(provider: string): string | null {
const trimmed = provider.trim();
if (!trimmed || /\s/.test(trimmed) || trimmed.includes('://') || trimmed.includes('/')) {
return null;
}

// Bare IPv6 (e.g. ::1) is not a valid URL authority; try the bracketed form first.
const candidates =
isIP(trimmed) === 6
? [`[${trimmed}]`]
: [trimmed];

for (const candidate of candidates) {
try {
const url = new URL(`https://${candidate}`);
const hostname = stripIpv6Brackets(url.hostname);
if (!hostname) continue;

const ipVersion = isIP(hostname);
const isLoopback =
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
const isDnsHost =
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(
hostname,
);

if (!isLoopback && ipVersion === 0 && !isDnsHost) {
continue;
}

const hostPart = ipVersion === 6 ? `[${hostname}]` : hostname;
return url.port ? `${hostPart}:${url.port}` : hostPart;
Comment thread
Minitour marked this conversation as resolved.
} catch {
// try next candidate
}
}

return null;
}

function stripIpv6Brackets(hostname: string): string {
return hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
}

async function pollForCompletion(
db: CapaDatabase,
provider: string,
Expand Down
2 changes: 1 addition & 1 deletion src/cli/utils/wrap/symlink-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ function symlinkErrorMessage(err: unknown): string {
* from `linkPath` to `targetPath`.
*/
export function createWorkspaceSymlink(targetPath: string, linkPath: string): void {
const targetIsDir = existsSync(targetPath) && statSync(targetPath).isDirectory();
try {
const targetIsDir = existsSync(targetPath) && statSync(targetPath).isDirectory();
if (isWin) {
if (targetIsDir) {
symlinkSync(targetPath, linkPath, 'junction');
Expand Down
2 changes: 1 addition & 1 deletion src/cli/utils/wrap/watch-project.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { watch, type FSWatcher, existsSync, lstatSync, readdirSync } from 'fs';
import { watch, type FSWatcher, lstatSync, readdirSync } from 'fs';
import { join, resolve, basename } from 'path';
import {
createWorkspaceSymlink,
Expand Down
40 changes: 40 additions & 0 deletions src/server/__tests__/cors-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect, afterEach } from 'bun:test';
import { isAllowedOrigin } from '../cors-origin';

describe('isAllowedOrigin', () => {
const prev = process.env.CAPA_ALLOWED_ORIGINS;

afterEach(() => {
if (prev === undefined) delete process.env.CAPA_ALLOWED_ORIGINS;
else process.env.CAPA_ALLOWED_ORIGINS = prev;
});

it('allows http localhost and IPv4 loopback', () => {
expect(isAllowedOrigin('http://localhost:5173')).toEqual({
allowed: true,
origin: 'http://localhost:5173',
});
expect(isAllowedOrigin('http://127.0.0.1:5912')).toEqual({
allowed: true,
origin: 'http://127.0.0.1:5912',
});
});

it('allows http IPv6 loopback', () => {
expect(isAllowedOrigin('http://[::1]:5173')).toEqual({
allowed: true,
origin: 'http://[::1]:5173',
});
});

it('rejects non-loopback origins unless listed in CAPA_ALLOWED_ORIGINS', () => {
delete process.env.CAPA_ALLOWED_ORIGINS;
expect(isAllowedOrigin('http://example.com')).toEqual({ allowed: false });

process.env.CAPA_ALLOWED_ORIGINS = 'https://app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toEqual({
allowed: true,
origin: 'https://app.example.com',
});
});
});
1 change: 0 additions & 1 deletion src/server/configure-routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { CapaDatabase } from "../db/database";
import { parseCapabilitiesFile } from "../shared/capabilities";
import { logger } from "../shared/logger";
import { detectCapabilitiesFile } from "../shared/paths";
import { projectUiUrl } from "../shared/ui-urls";
Expand Down
11 changes: 10 additions & 1 deletion src/server/cors-origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ export function isAllowedOrigin(origin: string | null): {

try {
const parsed = new URL(origin);
const hostname = stripIpv6Brackets(parsed.hostname);
if (
parsed.protocol === "http:" &&
(parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1")
(hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1")
) {
return { allowed: true, origin };
}
Expand All @@ -28,3 +31,9 @@ export function isAllowedOrigin(origin: string | null): {

return { allowed: false };
}

function stripIpv6Brackets(hostname: string): string {
return hostname.startsWith("[") && hostname.endsWith("]")
? hostname.slice(1, -1)
: hostname;
}
6 changes: 1 addition & 5 deletions src/server/mcp-meta-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ import { detectCapabilitiesFile } from "../shared/paths";
import type { Capabilities } from "../types/capabilities";
import type { CapaMCPServer, ShellToolInfo } from "./mcp-handler";
import type { SessionManager } from "./session-manager";
import {
resolveSkillContentById,
resolveSkillDescription,
resolveSkillSourceUrl,
} from "./skill-content";
import { resolveSkillContentById } from "./skill-content";

const JSON_HEADERS = { "Content-Type": "application/json" };

Expand Down
Loading