Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/social-weeks-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@inkeep/open-knowledge-desktop": patch
"@inkeep/open-knowledge-server": patch
"@inkeep/open-knowledge-core": patch
"@inkeep/open-knowledge-app": patch
"@inkeep/open-knowledge": patch
---

Fix GitHub Enterprise host handling for auth and sync.
3 changes: 2 additions & 1 deletion docs/content/features/github-sync.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ If you are logged in with the GitHub CLI, OpenKnowledge will use those credentia

Manage the connection from **Settings → Account**. **Connect GitHub** authorizes OpenKnowledge to browse and sync your repositories; **Disconnect** clears OpenKnowledge's GitHub token only. From a terminal, the [`ok auth`](/docs/reference/cli) subcommands manage the same credentials: `ok auth signout` matches **Disconnect**, `ok auth pat` stores a Personal Access Token when device authentication isn't an option, and `--host` targets GitHub Enterprise.

For projects whose `origin` points at a GitHub Enterprise Server host, OpenKnowledge uses that remote host for auth checks, token relay, and push-permission checks instead of falling back to `github.com`.

The sync status indicator surfaces an auth-error state when stored credentials stop working, and sync pauses until you reconnect.

## Sync status and conflicts
Expand Down Expand Up @@ -119,4 +121,3 @@ A few situations stop sync from completing successfully. In each case the sync s
### Temporary problems

- **Network or service hiccups.** Flaky Wi-Fi, a VPN drop, or a brief GitHub outage. OpenKnowledge keeps retrying in the background; no action needed unless the issue lasts long enough that you want to manually trigger a sync from the indicator.

46 changes: 46 additions & 0 deletions packages/app/src/components/AuthModal.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function renderModal(props: {
identityPrompt?: boolean;
queryTransport: AuthQueryTransport;
transport?: AuthTransport;
host?: string;
}) {
return render(
<AuthModal
Expand All @@ -108,6 +109,7 @@ function renderModal(props: {
identityPrompt={props.identityPrompt}
transport={props.transport ?? noopAuthTransport}
queryTransport={props.queryTransport}
host={props.host}
/>,
);
}
Expand All @@ -133,6 +135,50 @@ describe('AuthModal identityPrompt (set-identity) path', () => {
expect(screen.queryByText('Starting sign-in flow')).toBeNull();
});

test('identity probe queries the requested GitHub Enterprise host', async () => {
const hosts: Array<string | undefined> = [];
const queryTransport: AuthQueryTransport = {
status: async (request) => {
hosts.push(request?.host);
return { authenticated: false, host: request?.host ?? 'github.com' };
},
repos: async () => ({ ok: true, host: 'github.com', repos: [] }),
signout: async () => ({ ok: true }),
};

renderModal({
identityPrompt: true,
queryTransport,
host: 'github.enterprise.example',
});

await act(async () => {
await Promise.resolve();
});
expect(hosts).toEqual(['github.enterprise.example']);
});

test('device flow starts against the requested GitHub Enterprise host', async () => {
const starts: Array<string | undefined> = [];
const transport: AuthTransport = {
start: (request) => {
starts.push(request?.host);
return noopAuthTransport.start();
},
};

renderModal({
queryTransport: makeQueryTransport(NOT_CONNECTED),
transport,
host: 'github.enterprise.example',
});

await act(async () => {
await Promise.resolve();
});
expect(starts).toEqual(['github.enterprise.example']);
});

test('unauthenticated user falls back to the device flow', async () => {
renderModal({ identityPrompt: true, queryTransport: makeQueryTransport(NOT_CONNECTED) });

Expand Down
16 changes: 12 additions & 4 deletions packages/app/src/components/AuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,21 @@ interface AuthModalProps {
* path (POST /api/local-op/auth/status). Injectable for tests.
*/
queryTransport?: AuthQueryTransport;
/** GitHub/GHES host for project-scoped auth flows. Defaults to github.com. */
host?: string;
}

// ── Device Flow panel ─────────────────────────────────────────────────────────

interface DeviceFlowPanelProps {
onSuccess: (result: AuthSuccessResult) => void;
transport: AuthTransport;
host?: string;
}

const DEVICE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes

function DeviceFlowPanel({ onSuccess, transport }: DeviceFlowPanelProps) {
function DeviceFlowPanel({ onSuccess, transport, host }: DeviceFlowPanelProps) {
const { t } = useLingui();
const [userCode, setUserCode] = useState<string | null>(null);
const [verificationUri, setVerificationUri] = useState('https://github.com/login/device');
Expand All @@ -106,7 +109,7 @@ function DeviceFlowPanel({ onSuccess, transport }: DeviceFlowPanelProps) {
async function startDeviceFlow() {
setError(null);
try {
const handle = transport.start();
const handle = transport.start(host ? { host } : undefined);
cancelRef.current = handle.cancel;
// Manual iterator drive — React Compiler (BuildHIR) does not yet
// support `for await ... of` lowering, so we walk the iterator with
Expand Down Expand Up @@ -323,6 +326,7 @@ export function AuthModal({
reauth,
transport,
queryTransport,
host,
}: AuthModalProps) {
const { t } = useLingui();
// Default to the HTTP path so existing editor / web callers don't need
Expand Down Expand Up @@ -372,7 +376,7 @@ export function AuthModal({
};
const timer = setTimeout(() => settle('auth'), IDENTITY_PROBE_TIMEOUT_MS);
void resolvedQueryTransport
.status()
.status(host ? { host } : undefined)
.then((status) => {
if (settled) return;
if (status.authenticated) {
Expand Down Expand Up @@ -482,7 +486,11 @@ export function AuthModal({
{step === 'auth' && (
<>
<DialogBody>
<DeviceFlowPanel onSuccess={handleAuthSuccess} transport={resolvedTransport} />
<DeviceFlowPanel
onSuccess={handleAuthSuccess}
transport={resolvedTransport}
host={host}
/>
</DialogBody>

<DialogFooter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@ function SyncSection() {
open={authModalOpen}
onOpenChange={setAuthModalOpen}
onSuccess={() => setAuthModalOpen(false)}
host={status?.remote?.host}
/>
</section>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/hooks/use-git-sync-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface GitSyncStatus {
* recognized GitHub origins (rendered as a link); non-GitHub remotes carry
* a readable `label` with `webUrl: null`. Null/absent when no remote exists.
*/
remote?: { label: string; webUrl: string | null } | null;
remote?: { label: string; webUrl: string | null; host?: string } | null;
/**
* Per-direction error surfaces. `push*` = sending commits out; `pull*` =
* bringing remote changes in (fetch + merge). Tracked separately so a
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/lib/desktop-bridge-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,7 +1324,7 @@ export interface OkDesktopBridge {
*/
localOp: {
auth: {
start(): OkLocalOpStream<OkLocalOpAuthEvent>;
start(request?: { host?: string }): OkLocalOpStream<OkLocalOpAuthEvent>;
};
clone: {
start(request: {
Expand Down
13 changes: 8 additions & 5 deletions packages/app/src/lib/transports/auth-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ interface AuthTransportHandle {

export interface AuthTransport {
/** Start a new device-flow login. */
start(): AuthTransportHandle;
start(request?: { host?: string }): AuthTransportHandle;
}

/**
Expand All @@ -46,14 +46,17 @@ export interface AuthTransport {
*/
export function httpAuthTransport(): AuthTransport {
return {
start(): AuthTransportHandle {
start(request?: { host?: string }): AuthTransportHandle {
return createBufferedAsyncStream<AuthEvent>((push, signal) => {
void (async () => {
try {
const res = await fetch('/api/local-op/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ json: true }),
body: JSON.stringify({
json: true,
...(request?.host ? { host: request.host } : {}),
}),
signal,
});
if (!res.ok) {
Expand Down Expand Up @@ -140,8 +143,8 @@ export function httpAuthTransport(): AuthTransport {
*/
export function ipcAuthTransport(bridge: OkDesktopBridge): AuthTransport {
return {
start(): AuthTransportHandle {
return bridge.localOp.auth.start();
start(request?: { host?: string }): AuthTransportHandle {
return bridge.localOp.auth.start(request);
},
};
}
11 changes: 11 additions & 0 deletions packages/cli/src/auth/resolve-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ describe('resolveAuth', () => {
expect(result.credentialArgs).toEqual(['-c', 'credential.helper=!gh auth git-credential']);
});

test('Tier A detects gh auth for the requested host', async () => {
const store = makeStore(tmpDir);
const hosts: Array<string | undefined> = [];
const result = await resolveAuth('github.enterprise.example', store, {}, (host?: string) => {
hosts.push(host);
return { available: true, token: 'ghs_enterprise_token' };
});
expect(result.tier).toBe('A');
expect(hosts).toEqual(['github.enterprise.example']);
});

test('Tier A takes priority over stored token', async () => {
const store = makeStore(tmpDir);
await store.set('github.com', 'alice', 'gho_abc');
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/auth/resolve-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ export async function resolveAuth(
host: string,
tokenStore: TokenStore,
options: ResolveAuthOptions = {},
_detectGhFn: () => ReturnType<typeof detectGh> = detectGh,
_detectGhFn: (host?: string) => ReturnType<typeof detectGh> = detectGh,
): Promise<ResolvedAuth> {
// Tier A: gh CLI
if (!options.skipGhDetect) {
const gh = _detectGhFn();
const gh = _detectGhFn(host);
if (gh.available) {
return {
tier: 'A',
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schemas/api/sync-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export const SyncRemoteSchema = z
label: z.string().min(1),
/** Browsable https URL, or null when not a recognized GitHub remote. */
webUrl: z.url().nullable(),
/** GitHub host for recognized GitHub/GHES remotes. */
host: z.string().min(1).optional(),
})
.loose() satisfies StandardSchemaV1;
export type SyncRemoteWire = z.infer<typeof SyncRemoteSchema>;
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4392,8 +4392,8 @@ function registerIpcHandlers() {
resolveCliArgs: resolveLocalOpCliArgs,
state: createLocalOpState(),
};
handle('ok:local-op:auth:start', async (event) => {
const result = handleAuthStart(localOpDeps, event.sender);
handle('ok:local-op:auth:start', async (event, request) => {
const result = handleAuthStart(localOpDeps, event.sender, request);
if (!result.ok) {
logIpcError({
event: 'ipc.error',
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/main/ipc/local-op.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export interface LocalOpDeps {
export function handleAuthStart(
deps: LocalOpDeps,
sender: SendableWebContents,
request?: { host?: string },
): { ok: true; streamId: string } | { ok: false; error: string } {
const streamId = randomUUID();
// Renderer-side cleanup IPC is best-effort; on a missed `:cancel` the slot
Expand All @@ -128,6 +129,7 @@ export function handleAuthStart(
}
const controller = runDeviceFlowSubprocess({
cliArgs: deps.resolveCliArgs(),
host: request?.host,
onEvent: (event) => {
// The wrapper guards against sending to a destroyed webContents —
// window-close mid-flow would otherwise crash the main process.
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/shared/bridge-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1819,7 +1819,7 @@ export interface OkDesktopBridge {
* events; iteration ends after a terminal `complete` / `error`. Call
* `cancel()` to abort early.
*/
start(): OkLocalOpStream<OkLocalOpAuthEvent>;
start(request?: { host?: string }): OkLocalOpStream<OkLocalOpAuthEvent>;
};
clone: {
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/shared/ipc-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1366,7 +1366,7 @@ export interface RequestChannels {
* flow per channel; concurrent starts return `error: 'busy'`.
*/
'ok:local-op:auth:start': {
args: [];
args: [request?: { host?: string }];
result: { ok: true; streamId: string } | { ok: false; error: string };
};
'ok:local-op:auth:cancel': { args: [streamId: string]; result: undefined };
Expand Down
Loading