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
5 changes: 5 additions & 0 deletions .changeset/ssh-auth-sock-harvest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge-desktop": patch
---

Git sync now works when SSH keys live in an external SSH agent (1Password, Proton Pass, a custom `ssh-agent`). Finder-launched apps inherit macOS's default agent socket instead of the one your shell exports, so pushes over SSH failed with "Permission denied (publickey)" while terminal git worked. At startup the desktop app now reads `SSH_AUTH_SOCK` from your login shell and passes it to every git operation.
15 changes: 15 additions & 0 deletions .changeset/ssh-origin-probe-leniency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@inkeep/open-knowledge': patch
'@inkeep/open-knowledge-server': patch
'@inkeep/open-knowledge-core': patch
---

**Fix:** auto-sync no longer silently pauses for SSH-origin remotes with no
GitHub credential. The push-permission probe used to treat "no gh/OK token"
as signed-out and pause sync with a Sign-in prompt — wrong for self-hosted
forges (Gitea/Forgejo) and github.com-over-SSH setups, where pushes
authenticate with SSH keys and no OK sign-in path can ever help. The probe
now keys off the origin transport: HTTPS origins keep the signed-out denial
(and its Sign-in affordance, including GHES); `ssh://`, scp-style, and
`git://` origins abstain with a new `unknown/ssh-unverified` result, so sync
proceeds and the real push decides.
7 changes: 7 additions & 0 deletions packages/app/src/components/SyncStatusBadge.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ describe('SyncStatusBadge helper behavior', () => {
);
expect(shouldOfferSignInAgain({ checkStatus: 'denied' })).toBe(false);
expect(shouldOfferSignInAgain({ checkStatus: 'unknown', unknownError: 'network' })).toBe(false);
// ssh-unverified is the abstaining probe result for SSH-origin repos with
// no GitHub credential. Signing in can never help there (push auths with
// SSH keys), so broadening this predicate to match it would resurrect the
// misleading sign-in affordance the transport-keyed probe fix removed.
expect(shouldOfferSignInAgain({ checkStatus: 'unknown', unknownError: 'ssh-unverified' })).toBe(
false,
);
expect(shouldOfferSignInAgain(undefined)).toBe(false);
});
});
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/schemas/api/sync-seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ConflictEntrySchema,
InstallSkillSuccessSchema,
ProblemTypeSchema,
PushPermissionSchema,
SeedApplyRequestSchema,
SeedApplySuccessSchema,
SeedPlanSuccessSchema,
Expand Down Expand Up @@ -123,6 +124,32 @@ describe('SyncStatusSchema', () => {
});
});

describe('PushPermissionSchema', () => {
test('parses every unknown variant including ssh-unverified', () => {
for (const unknownError of [
'network',
'timeout',
'rate-limit',
'token-invalid',
'malformed-response',
'ssh-unverified',
]) {
const parsed = PushPermissionSchema.safeParse({ checkStatus: 'unknown', unknownError });
expect(parsed.success).toBe(true);
}
});
test('round-trips the ssh-unverified member', () => {
const wire = { checkStatus: 'unknown' as const, unknownError: 'ssh-unverified' as const };
const parsed = PushPermissionSchema.parse(wire);
expect(parsed).toEqual(wire);
});
test('rejects an unlisted unknownError code', () => {
expect(
PushPermissionSchema.safeParse({ checkStatus: 'unknown', unknownError: 'bogus' }).success,
).toBe(false);
});
});

describe('SyncRemoteSchema', () => {
test('accepts a github remote with label + https webUrl', () => {
expect(
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/schemas/api/sync-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,14 @@ export const PushPermissionSchema = z.discriminatedUnion('checkStatus', [
.object({
checkStatus: z.literal('unknown'),
unknownError: z
.enum(['network', 'timeout', 'rate-limit', 'token-invalid', 'malformed-response'])
.enum([
'network',
'timeout',
'rate-limit',
'token-invalid',
'malformed-response',
'ssh-unverified',
])
.optional(),
})
.loose(),
Expand Down
33 changes: 33 additions & 0 deletions packages/desktop/src/main/git-spawn-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from 'vitest';
import { gitSpawnEnv } from './git-spawn-env.ts';

const ORIGINAL_SOCK = process.env.SSH_AUTH_SOCK;

afterEach(() => {
if (ORIGINAL_SOCK === undefined) {
delete process.env.SSH_AUTH_SOCK;
} else {
process.env.SSH_AUTH_SOCK = ORIGINAL_SOCK;
}
});

describe('gitSpawnEnv', () => {
it('pins an English locale', () => {
const env = gitSpawnEnv();
expect(env.LANG).toBe('C');
expect(env.LC_ALL).toBe('C');
});

it('reflects SSH_AUTH_SOCK changes made after a prior call', () => {
process.env.SSH_AUTH_SOCK = '/tmp/before.sock';
expect(gitSpawnEnv().SSH_AUTH_SOCK).toBe('/tmp/before.sock');
// The startup harvest patches process.env once; a frozen snapshot here
// would pin every later git spawn to the pre-harvest socket.
process.env.SSH_AUTH_SOCK = '/tmp/after.sock';
expect(gitSpawnEnv().SSH_AUTH_SOCK).toBe('/tmp/after.sock');
});

it('keeps the augmented PATH stable across calls', () => {
expect(gitSpawnEnv().PATH).toBe(gitSpawnEnv().PATH);
});
});
38 changes: 22 additions & 16 deletions packages/desktop/src/main/git-spawn-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,22 @@
* (`detectMissingGitHelper`, the add-error / branch-gone matchers) survives a
* non-English host locale — same discipline as the server's `buildGitEnv`.
*
* Computed lazily and cached: the augmentation stats well-known directories,
* and PATH/homedir don't change within a process lifetime.
* Only the PATH augmentation is cached (it stats well-known directories, and
* PATH/homedir don't change within a process lifetime). The env object itself
* is rebuilt from live `process.env` on every call: startup corrects
* `SSH_AUTH_SOCK` once via the login-shell harvest (`shell-env.ts` /
* `applyHarvestedAuthSock`), and a cached snapshot taken before that point
* would pin every later git spawn to launchd's default-agent socket. For the
* same reason, callers must not freeze this function's result into
* module-level constants — call it per spawn.
*/

import { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { delimiter } from 'node:path';
import { augmentGitSpawnPath } from '@inkeep/open-knowledge-core';

let cached: Record<string, string | undefined> | null = null;
let cachedPath: string | null = null;

/** True iff `dir` exists and is a directory (symlinks followed). */
function isDir(dir: string): boolean {
Expand All @@ -40,18 +46,18 @@ function isDir(dir: string): boolean {
* share-fetch arm's `GIT_TERMINAL_PROMPT=0`).
*/
export function gitSpawnEnv(): Record<string, string | undefined> {
if (cached === null) {
cached = {
...process.env,
LANG: 'C',
LC_ALL: 'C',
PATH: augmentGitSpawnPath(process.env.PATH, {
platform: process.platform,
homeDir: homedir(),
isDir,
delimiter,
}),
};
if (cachedPath === null) {
cachedPath = augmentGitSpawnPath(process.env.PATH, {
platform: process.platform,
homeDir: homedir(),
isDir,
delimiter,
});
}
return cached;
return {
...process.env,
LANG: 'C',
LC_ALL: 'C',
PATH: cachedPath,
};
}
21 changes: 21 additions & 0 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ import { handleRevealExternal } from './reveal-external.ts';
import { createServerExitRecorder, type ServerExitRecorder } from './server-exit-record.ts';
import { startFirstRunHandshake } from './share-handoff.ts';
import { checkOutboundUrl, handleShellOpenExternal } from './shell-allowlist.ts';
import { applyHarvestedAuthSock, harvestShellAuthSock } from './shell-env.ts';
import { createShowGateRegistry, type ShowGateRegistry } from './show-gate.ts';
import { reclaimProjectSkillsOnProjectOpen, reclaimUserSkillsOnLaunch } from './skill-reclaim.ts';
import { attachSpellcheckContextMenu } from './spellcheck-context-menu.ts';
Expand Down Expand Up @@ -5672,6 +5673,18 @@ function bootPrimaryInstance(): void {
// its return tells the waterfall whether main spans are live.
startupWaterfall.mark('appReady');
startupWaterfall.otelEnabled = beginRoot();
// Login-shell SSH_AUTH_SOCK harvest — started here so its (2s-bounded)
// shell spawn overlaps the bootstrap I/O below; awaited + applied just
// before the window-open branch, ahead of the git preflight and both
// server-spawn paths (utility fork + detached spawn). Desktop-main git
// spawns pick the corrected value up automatically: gitSpawnEnv()
// rebuilds from live process.env per call and must never be frozen
// into a module-level constant (see git-spawn-env.ts).
const shellEnvLogger = {
event: (payload: Record<string, unknown> & { event: string }) =>
getLogger('shell-env').info(payload, payload.event),
};
const authSockHarvest = harvestShellAuthSock({ logger: shellEnvLogger });
// One-time userData migration for the "Open Knowledge" → "OpenKnowledge"
// rename. Dormant until the packaged productName flips the userData
// basename to "OpenKnowledge"; then it relocates a verified-ours legacy
Expand Down Expand Up @@ -5844,6 +5857,14 @@ function bootPrimaryInstance(): void {
});
});

// Apply the harvested login-shell SSH_AUTH_SOCK before the window-open
// branch. A Finder launch inherits launchd's default-agent socket, which
// holds no keys for external-agent users (1Password, Proton Pass) —
// patching process.env here lets every downstream git spawn inherit the
// agent the user's terminal actually uses. Failure or an empty value
// leaves the inherited socket untouched.
applyHarvestedAuthSock(process.env, await authSockHarvest, shellEnvLogger);

// Every project open spawns a NEW editor window. Boot restore order:
// 1. An update relaunch left a `pendingWindowRestore` snapshot — open
// EVERY project that was open before the relaunch, not just the
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/main/path-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ function installCanonical(home: string, wrapper: string, fs: PathInstallFsOps):
}
}

async function defaultSpawn(
export async function defaultSpawn(
command: string,
args: string[],
opts: { timeoutMs: number; env: Record<string, string | undefined> },
Expand Down
Loading