From 6512f5ee4d45b7f22f22462effab27b7abd57803 Mon Sep 17 00:00:00 2001 From: omar-inkeep Date: Wed, 22 Jul 2026 12:43:56 -0400 Subject: [PATCH 1/2] fix(desktop): harvest login-shell SSH_AUTH_SOCK for git sync (#2835) * fix(desktop): harvest login-shell SSH_AUTH_SOCK for git sync Finder-launched Electron inherits launchd's SSH_AUTH_SOCK, which points at Apple's default ssh-agent. Users whose keys live in an external SSH agent (1Password, Proton Pass) export the real socket path in their shell rc, so terminal git works while in-app sync fails with Permission denied (publickey). The forwarding in buildGitEnv was already correct; the value it forwarded was wrong from birth. Spawn one interactive login shell at startup (same pattern and spawn discipline as path-install's PATH discovery, sentinel-delimited so rc noise cannot corrupt the value), read SSH_AUTH_SOCK, and patch process.env before the git preflight and both server spawn paths. A failed, timed-out, or empty harvest leaves the inherited socket untouched. Verified against a live repro: stock ssh-agent on a custom socket exported only via .zshrc, key registered with GitHub, packaged Finder-launched build. Sync fails before the change, succeeds after, and fails again when the rc export is removed. Fixes inkeep/open-knowledge#669 * chore(scripts): brace OK_DIR expansion in setup-open-knowledge echo Under macOS bash 3.2 with a non-UTF-8 locale, the bare $OK_DIR followed immediately by a multibyte ellipsis parses the ellipsis into the variable name, and set -u aborts the script with 'OK_DIR: unbound variable' before the install runs. Bracing the expansion makes the script locale-proof. * fix(desktop): carry stderr and from/to values in authsock log events Review follow-up on the SSH_AUTH_SOCK harvest: the non-zero-exit failure event now includes the shell's stderr (truncated to 300 chars, since a broken rc file can produce unbounded output), and the success event records the previous and new socket values so a correct upgrade is distinguishable from a stale-path regression in field logs. * fix(desktop): make gitSpawnEnv read live env so the harvest reaches all git spawns Review follow-up: worktree-service and worktree-recents froze gitSpawnEnv() into module-level constants at import time, and gitSpawnEnv itself cached the whole env object on first call. Either way the share-fetch and worktree git spawns kept the pre-harvest launchd SSH_AUTH_SOCK, so the external-agent fix never reached that transport. gitSpawnEnv now caches only the augmented PATH (the expensive stat walk) and rebuilds the env from live process.env per call. The two module-level constants become per-call lookups, FETCH_GIT_ENV becomes fetchGitEnv(), and a new git-spawn-env test pins the live-read contract so a future snapshot regression fails fast. The index.ts wiring comment now states the actual guarantee instead of overclaiming lazy-cache coverage. Also carries the marker-missing harvest failure's bounded stdout, the one diagnostic explaining why markers were absent. GitOrigin-RevId: feab7f47b1b96fb359c69386d8841f25e1eba9b5 --- .changeset/ssh-auth-sock-harvest.md | 5 + knip.config.ts | 4 +- .../core/src/bridge/bind-frontmatter-doc.ts | 1 + packages/core/src/bridge/bridge-invariant.ts | 1 + .../core/src/bridge/doc-boundary-space.ts | 1 + packages/core/src/bridge/growth-detect.ts | 1 + packages/core/src/bridge/merge-three-way.ts | 1 + packages/core/src/bridge/normalize.ts | 1 + .../src/bridge/pm-structural-equivalence.ts | 3 + packages/core/src/bridge/subsequence.ts | 1 + .../core/src/markdown/callout-transformer.ts | 1 + .../core/src/markdown/comment-promoter.ts | 1 + .../src/markdown/dedent-block-jsx-close.ts | 1 + .../markdown/details-accordion-promoter.ts | 1 + packages/core/src/markdown/fence-regions.ts | 1 + packages/core/src/markdown/fixtures/index.ts | 6 + .../src/markdown/fixtures/perf/generate.ts | 5 + .../src/markdown/guard-flanking-matrix.ts | 1 + .../core/src/markdown/handler-shadow-audit.ts | 1 + .../core/src/markdown/highlight-promoter.ts | 1 + packages/core/src/markdown/html-to-mdast.ts | 4 +- packages/core/src/markdown/image-promoter.ts | 1 + packages/core/src/markdown/index.ts | 9 + .../core/src/markdown/lint/config-files.ts | 1 + .../core/src/markdown/lint/config-schemas.ts | 1 + .../core/src/markdown/lint/default-config.ts | 1 + packages/core/src/markdown/lint/index.ts | 1 + packages/core/src/markdown/lint/plugins.ts | 2 + packages/core/src/markdown/lint/types.ts | 2 + packages/core/src/markdown/math-promoter.ts | 1 + .../src/markdown/mdast-to-hast-handlers.ts | 1 + packages/core/src/markdown/mdast-to-html.ts | 1 + packages/core/src/markdown/merged-walker.ts | 1 + .../core/src/markdown/mermaid-promoter.ts | 1 + .../core/src/markdown/parse-with-fallback.ts | 5 + .../core/src/markdown/parser-drop-closure.ts | 1 + packages/core/src/markdown/pipeline.ts | 1 + .../core/src/markdown/position-aware-join.ts | 1 + packages/core/src/markdown/position-slice.ts | 4 +- .../rehype-plugins/skip-notion-whitespace.ts | 1 + .../rehype-plugins/strip-cocoa-meta.ts | 1 + .../rehype-plugins/strip-gdocs-wrapper.ts | 1 + .../rehype-plugins/strip-github-hovercard.ts | 1 + .../rehype-plugins/strip-gmail-classes.ts | 1 + .../rehype-plugins/strip-gsheets-wrapper.ts | 1 + .../rehype-plugins/strip-mso-styles.ts | 1 + .../rehype-plugins/strip-slack-classes.ts | 1 + .../rehype-plugins/strip-vscode-spans.ts | 1 + .../core/src/markdown/resolve-image-url.ts | 1 + packages/core/src/markdown/safe-url.ts | 1 + .../core/src/markdown/serialize-helpers.ts | 1 + .../markdown/single-dollar-math-promoter.ts | 1 + .../core/src/markdown/to-markdown-handlers.ts | 1 + .../core/src/markdown/unknown-mdast-guard.ts | 1 + .../core/src/markdown/whitespace-char-ref.ts | 1 + .../core/src/markdown/wiki-link-micromark.ts | 3 + .../desktop/src/main/git-spawn-env.test.ts | 33 +++ packages/desktop/src/main/git-spawn-env.ts | 38 ++-- packages/desktop/src/main/index.ts | 21 ++ packages/desktop/src/main/path-install.ts | 2 +- packages/desktop/src/main/shell-env.test.ts | 210 ++++++++++++++++++ packages/desktop/src/main/shell-env.ts | 122 ++++++++++ packages/desktop/src/main/worktree-recents.ts | 10 +- packages/desktop/src/main/worktree-service.ts | 29 +-- 64 files changed, 521 insertions(+), 40 deletions(-) create mode 100644 .changeset/ssh-auth-sock-harvest.md create mode 100644 packages/desktop/src/main/git-spawn-env.test.ts create mode 100644 packages/desktop/src/main/shell-env.test.ts create mode 100644 packages/desktop/src/main/shell-env.ts diff --git a/.changeset/ssh-auth-sock-harvest.md b/.changeset/ssh-auth-sock-harvest.md new file mode 100644 index 000000000..c6800d869 --- /dev/null +++ b/.changeset/ssh-auth-sock-harvest.md @@ -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. diff --git a/knip.config.ts b/knip.config.ts index 296751a02..6e3847861 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -121,7 +121,9 @@ export default { 'packages/server': { entry: ['src/**/*.test.ts', 'src/parse-worker.ts'], project: 'src/**', - ignoreDependencies: ['@types/shell-quote'], + ignoreDependencies: [ + '@types/shell-quote', + ], }, 'packages/cli': { entry: ['src/**/*.test.ts', 'scripts/*.ts', 'tests/**/*.ts', 'src/parse-worker.ts'], diff --git a/packages/core/src/bridge/bind-frontmatter-doc.ts b/packages/core/src/bridge/bind-frontmatter-doc.ts index c014b1413..8f9d95287 100644 --- a/packages/core/src/bridge/bind-frontmatter-doc.ts +++ b/packages/core/src/bridge/bind-frontmatter-doc.ts @@ -1,3 +1,4 @@ + import type * as Y from 'yjs'; import type { Err, Ok, Result } from '../config/result.ts'; import { type FrontmatterValidationError, toFrontmatterIssue } from '../frontmatter/errors.ts'; diff --git a/packages/core/src/bridge/bridge-invariant.ts b/packages/core/src/bridge/bridge-invariant.ts index 005627195..8bfdb40cd 100644 --- a/packages/core/src/bridge/bridge-invariant.ts +++ b/packages/core/src/bridge/bridge-invariant.ts @@ -1,3 +1,4 @@ + import { fnv1aDigest } from './hash-util.ts'; export type BridgeInvariantSite = 'observer-b' | 'persistence' | 'test-harness'; diff --git a/packages/core/src/bridge/doc-boundary-space.ts b/packages/core/src/bridge/doc-boundary-space.ts index 8890f6739..c18c086d5 100644 --- a/packages/core/src/bridge/doc-boundary-space.ts +++ b/packages/core/src/bridge/doc-boundary-space.ts @@ -1,3 +1,4 @@ + import { FRONTMATTER_RE, stripFrontmatter } from '../extensions/frontmatter.ts'; const LEADING_BOUNDARY_RE = /^(?:\r?\n)+/; diff --git a/packages/core/src/bridge/growth-detect.ts b/packages/core/src/bridge/growth-detect.ts index 9d9c244b0..463e89595 100644 --- a/packages/core/src/bridge/growth-detect.ts +++ b/packages/core/src/bridge/growth-detect.ts @@ -1,3 +1,4 @@ + const DEFAULT_MIN_SUBSTANTIVE_LINE_LENGTH = 16; export const DUPLICATION_GATE_MIN_LINE_LENGTH = 8; diff --git a/packages/core/src/bridge/merge-three-way.ts b/packages/core/src/bridge/merge-three-way.ts index 3ab9cc6ef..3c927eb47 100644 --- a/packages/core/src/bridge/merge-three-way.ts +++ b/packages/core/src/bridge/merge-three-way.ts @@ -38,6 +38,7 @@ function mergeThreeWayImpl(baseline: string, userText: string, agentText: string return parts.join('\n'); } + export type BridgeMergeContentLossSide = 'user' | 'agent'; export type BridgeMergeContentLossWhich = 'substring' | 'order' | 'growth'; diff --git a/packages/core/src/bridge/normalize.ts b/packages/core/src/bridge/normalize.ts index 4b8890476..3f0880aa5 100644 --- a/packages/core/src/bridge/normalize.ts +++ b/packages/core/src/bridge/normalize.ts @@ -1,3 +1,4 @@ + const COMMONMARK_ESCAPE_RE = /\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~])/g; const TABLE_ALIGN_ROW_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/; diff --git a/packages/core/src/bridge/pm-structural-equivalence.ts b/packages/core/src/bridge/pm-structural-equivalence.ts index 754dc5cc2..0be5203e4 100644 --- a/packages/core/src/bridge/pm-structural-equivalence.ts +++ b/packages/core/src/bridge/pm-structural-equivalence.ts @@ -32,6 +32,7 @@ const BR_SENTINEL: PmStructuralNode = { type: '__br__' }; const BR_LITERAL_RE = /^$/i; + interface DegradeEntry { readonly label: string; readonly severity: ToleranceClassSeverity; @@ -127,6 +128,7 @@ export interface ComparePmStructuralOptions { ignoreAttrs?: (attrKey: string) => boolean; } + /** Rebuild a node with `fn` applied to each child; identity when childless. * Shared by the degrade canonicalizers so none re-implements the walk. */ function mapChildren( @@ -248,6 +250,7 @@ function applyDegrades(tree: PmStructuralNode): { return { normalized: current, fired }; } + export function comparePmStructural( expected: PmStructuralNode, actual: PmStructuralNode, diff --git a/packages/core/src/bridge/subsequence.ts b/packages/core/src/bridge/subsequence.ts index af20de1e2..dca644141 100644 --- a/packages/core/src/bridge/subsequence.ts +++ b/packages/core/src/bridge/subsequence.ts @@ -1,3 +1,4 @@ + /** True when `needle` is a subsequence of `haystack` (two-pointer, O(n+m)): * insertions in `haystack` are free; any dropped or substituted `needle` byte * fails. */ diff --git a/packages/core/src/markdown/callout-transformer.ts b/packages/core/src/markdown/callout-transformer.ts index 83d47404a..a363dfd54 100644 --- a/packages/core/src/markdown/callout-transformer.ts +++ b/packages/core/src/markdown/callout-transformer.ts @@ -1,3 +1,4 @@ + import type { Blockquote, Paragraph, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/comment-promoter.ts b/packages/core/src/markdown/comment-promoter.ts index 193e286b0..47f0d07e4 100644 --- a/packages/core/src/markdown/comment-promoter.ts +++ b/packages/core/src/markdown/comment-promoter.ts @@ -1,3 +1,4 @@ + import type { Nodes, Paragraph, PhrasingContent, Root, RootContent, Text } from 'mdast'; import { SKIP, visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/dedent-block-jsx-close.ts b/packages/core/src/markdown/dedent-block-jsx-close.ts index ece5c02a1..b6299a948 100644 --- a/packages/core/src/markdown/dedent-block-jsx-close.ts +++ b/packages/core/src/markdown/dedent-block-jsx-close.ts @@ -1,3 +1,4 @@ + import { findFencedRegions, isInsideFence } from './fence-regions.ts'; const INDENTED_BLOCK_JSX_CLOSE_RE = /^([ ]{1,3})(<\/[A-Z][A-Za-z0-9_]*\s*>)([ \t]*)$/gm; diff --git a/packages/core/src/markdown/details-accordion-promoter.ts b/packages/core/src/markdown/details-accordion-promoter.ts index 54774764a..acd398aa6 100644 --- a/packages/core/src/markdown/details-accordion-promoter.ts +++ b/packages/core/src/markdown/details-accordion-promoter.ts @@ -1,3 +1,4 @@ + import type { Nodes, Paragraph, Parent, Root, Text } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/fence-regions.ts b/packages/core/src/markdown/fence-regions.ts index c7518d614..fb5ac8e8a 100644 --- a/packages/core/src/markdown/fence-regions.ts +++ b/packages/core/src/markdown/fence-regions.ts @@ -1,3 +1,4 @@ + const FENCE_RE = /^(`{3,}|~{3,})/gm; export function findFencedRegions(src: string): Array<[number, number]> { diff --git a/packages/core/src/markdown/fixtures/index.ts b/packages/core/src/markdown/fixtures/index.ts index 00f5771ee..6931623c7 100644 --- a/packages/core/src/markdown/fixtures/index.ts +++ b/packages/core/src/markdown/fixtures/index.ts @@ -1,3 +1,4 @@ + import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,6 +9,7 @@ function fixturePath(...segments: string[]): string { return resolve(FIXTURES_DIR, ...segments); } + interface GfmExample { section: string; markdown: string; @@ -17,6 +19,7 @@ export function loadGfmExamples(): GfmExample[] { return JSON.parse(readFileSync(fixturePath('gfm', 'examples.json'), 'utf8')) as GfmExample[]; } + interface MdxCrashEntry { id: string; input: string; @@ -68,6 +71,7 @@ export function loadLargeEmbedFixtures(): LargeEmbedFixture[] { ) as LargeEmbedFixture[]; } + export function loadPrd6955Before(): string { return readFileSync(fixturePath('regression', 'prd-6955-before.md'), 'utf8'); } @@ -76,6 +80,7 @@ export function loadPrd6955CorruptedTriplicated(): string { return readFileSync(fixturePath('regression', 'prd-6955-corrupted-triplicated.md'), 'utf8'); } + export interface NgPinnedCase { id: string; name: string; @@ -92,6 +97,7 @@ export function loadNgPinnedCases(): NgPinnedCase[] { ) as NgPinnedCase[]; } + export function loadLargeRealistic(): string { return readFileSync(fixturePath('perf', 'large-realistic.md'), 'utf8'); } diff --git a/packages/core/src/markdown/fixtures/perf/generate.ts b/packages/core/src/markdown/fixtures/perf/generate.ts index 8b1eb8519..daa3ef82b 100644 --- a/packages/core/src/markdown/fixtures/perf/generate.ts +++ b/packages/core/src/markdown/fixtures/perf/generate.ts @@ -1,3 +1,4 @@ + import { writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -10,6 +11,7 @@ const BASELINE_ONLY_COUNTS = [500, 2500] as const; const SEED = 0xf1de1117; + function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { @@ -36,6 +38,7 @@ function pickWeighted(rand: () => number, items: readonly [T, number][]): T { return items[items.length - 1][0]; } + const WORDS = [ 'lorem', 'ipsum', @@ -153,6 +156,7 @@ function mdxBlock(rand: () => number): string { return `<${name}>\n\n${sentence(rand)}\n\n`; } + type BlockKind = 'paragraph' | 'heading' | 'list' | 'code' | 'table' | 'mdx'; const MIX: readonly [BlockKind, number][] = [ @@ -191,6 +195,7 @@ function generateDocument(blockCount: number, seed: number): string { return `${blocks.join('\n\n')}\n`; } + function main(): void { for (const count of [...BLOCK_COUNTS, ...BASELINE_ONLY_COUNTS]) { const doc = generateDocument(count, SEED); diff --git a/packages/core/src/markdown/guard-flanking-matrix.ts b/packages/core/src/markdown/guard-flanking-matrix.ts index 420dc0fe5..32c055c5b 100644 --- a/packages/core/src/markdown/guard-flanking-matrix.ts +++ b/packages/core/src/markdown/guard-flanking-matrix.ts @@ -7,6 +7,7 @@ import { import { BACKSLASH_GUARD_SUBSTITUTIONS, encodeBackslashEscapes } from './backslash-escape-guard.ts'; import { ENTITY_REF_GUARD_SUBSTITUTIONS, encodeEntityRefs } from './entity-ref-guard.ts'; + export const ATTENTION_DELIMITERS = ['*', '_', '**', '~~', '=='] as const; export type FlankClass = 'whitespace' | 'punctuation' | 'other'; diff --git a/packages/core/src/markdown/handler-shadow-audit.ts b/packages/core/src/markdown/handler-shadow-audit.ts index 2653473da..283913255 100644 --- a/packages/core/src/markdown/handler-shadow-audit.ts +++ b/packages/core/src/markdown/handler-shadow-audit.ts @@ -1,3 +1,4 @@ + export interface HandlerShadowWitness { input: string; expect: 'byte' | 'byte-and-reparse-type'; diff --git a/packages/core/src/markdown/highlight-promoter.ts b/packages/core/src/markdown/highlight-promoter.ts index f55912407..6319f264f 100644 --- a/packages/core/src/markdown/highlight-promoter.ts +++ b/packages/core/src/markdown/highlight-promoter.ts @@ -1,3 +1,4 @@ + import type { Nodes, Parent, PhrasingContent, Root, Text } from 'mdast'; import type { MdxJsxTextElement } from 'mdast-util-mdx'; import { SKIP, visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/html-to-mdast.ts b/packages/core/src/markdown/html-to-mdast.ts index b1f252c31..8dc3a81b5 100644 --- a/packages/core/src/markdown/html-to-mdast.ts +++ b/packages/core/src/markdown/html-to-mdast.ts @@ -1,3 +1,4 @@ + import type { Root as HastRoot } from 'hast'; import type { List, Root as MdastRoot } from 'mdast'; import rehypeParse from 'rehype-parse'; @@ -85,7 +86,8 @@ export function htmlToMdast(html: string, options?: HtmlToMdastOptions): MdastRo throw new HtmlPayloadTooLargeError(html.length, maxBytes); } - const processor = unified().use(rehypeParse, { fragment: true }); + const processor = unified() + .use(rehypeParse, { fragment: true }); for (const plugin of cleanupPlugins) { processor.use(plugin); diff --git a/packages/core/src/markdown/image-promoter.ts b/packages/core/src/markdown/image-promoter.ts index 706fa9afb..994eb6917 100644 --- a/packages/core/src/markdown/image-promoter.ts +++ b/packages/core/src/markdown/image-promoter.ts @@ -1,3 +1,4 @@ + import type { Image, Paragraph, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/index.ts b/packages/core/src/markdown/index.ts index 8b36807fd..fc30bda02 100644 --- a/packages/core/src/markdown/index.ts +++ b/packages/core/src/markdown/index.ts @@ -1,3 +1,4 @@ + import { type FromProseMirrorOptions, fromPmMark, @@ -196,6 +197,7 @@ export class MarkdownManager { } } + const registry = createRegistry(); function destructureAttrs( @@ -244,6 +246,7 @@ function destructureAttrs( return result; } + function hasDirtyDescendant(node: PmNode): boolean { let found = false; node.descendants((child) => { @@ -263,6 +266,7 @@ function effectiveDirty(node: PmNode, freshnessChecker?: StructuralFreshnessChec return freshnessChecker ? freshnessChecker.isDiverged(node.toJSON()) : false; } + function isEmptyMdastParagraph(node: MdastNodes): boolean { if (node.type !== 'paragraph') return false; const children = node.children ?? []; @@ -331,6 +335,7 @@ function extractTextFromMdastNodes(nodes: MdastNodes[]): string { return out; } + import { AUDIO_EXTENSIONS, FILE_ATTACHMENT_EXTENSIONS, @@ -577,6 +582,7 @@ function buildMdastToPmHandlers( })); } + if (m.emphasis) { handlers.emphasis = toPmMark(m.emphasis, (node: Emphasis) => ({ sourceDelimiter: node.data?.sourceDelimiter ?? '*', @@ -657,6 +663,7 @@ function buildMdastToPmHandlers( })); } + if (m.link) { const sourceLiteralMark = m.sourceLiteral; handlers.link = (node: Link, _parent: MdastParent, state: MdastToPmState) => { @@ -935,6 +942,7 @@ function buildMdastToPmHandlers( }; } + const blockUnknownHandler = (node: { type: string; position?: { start: { offset: number }; end: { offset: number } }; @@ -1060,6 +1068,7 @@ function buildMdastToPmHandlers( return handlers as RemarkProseMirrorOptions['handlers']; } + function buildPmToMdastHandlers( schema: Schema, freshness: FreshnessCheckerHolder, diff --git a/packages/core/src/markdown/lint/config-files.ts b/packages/core/src/markdown/lint/config-files.ts index 312c9c74b..94b34dee1 100644 --- a/packages/core/src/markdown/lint/config-files.ts +++ b/packages/core/src/markdown/lint/config-files.ts @@ -1,3 +1,4 @@ + const MARKDOWNLINT_JSON_CONFIG_FILES: ReadonlySet = new Set([ '.markdownlint.json', '.markdownlint.jsonc', diff --git a/packages/core/src/markdown/lint/config-schemas.ts b/packages/core/src/markdown/lint/config-schemas.ts index e78a1381d..207cd95c2 100644 --- a/packages/core/src/markdown/lint/config-schemas.ts +++ b/packages/core/src/markdown/lint/config-schemas.ts @@ -29,6 +29,7 @@ const fullPluginShape = Object.fromEntries( LINT_PLUGINS.map((plugin) => [plugin.id, plugin.sliceSchema]), ) as z.ZodRawShape; + export const LinterConfigSchema = z.object({ enabled: z.boolean(), plugins: z.object(fullPluginShape), diff --git a/packages/core/src/markdown/lint/default-config.ts b/packages/core/src/markdown/lint/default-config.ts index d48eea5e4..a51f2ffb0 100644 --- a/packages/core/src/markdown/lint/default-config.ts +++ b/packages/core/src/markdown/lint/default-config.ts @@ -6,6 +6,7 @@ export const DEFAULT_MARKDOWNLINT_CONFIG: Record | undefined, ): Configuration { diff --git a/packages/core/src/markdown/lint/index.ts b/packages/core/src/markdown/lint/index.ts index 8a154ce30..d763d8d28 100644 --- a/packages/core/src/markdown/lint/index.ts +++ b/packages/core/src/markdown/lint/index.ts @@ -1,3 +1,4 @@ + import { LINT_PLUGINS, type LinterConfig } from './plugins.ts'; import type { LintDiagnostic } from './types.ts'; diff --git a/packages/core/src/markdown/lint/plugins.ts b/packages/core/src/markdown/lint/plugins.ts index 1cd40ef3a..65a1b564c 100644 --- a/packages/core/src/markdown/lint/plugins.ts +++ b/packages/core/src/markdown/lint/plugins.ts @@ -1,3 +1,4 @@ + import { z } from 'zod'; import { DEFAULT_MARKDOWNLINT_CONFIG, resolveMarkdownlintConfig } from './default-config.ts'; import { fixMarkdownText, runMarkdownlint } from './markdownlint-runner.ts'; @@ -8,6 +9,7 @@ import { type MarkdownlintSlice, } from './types.ts'; + const MarkdownlintRuleSettingSchema = z.union([ z.boolean(), z.enum(MARKDOWNLINT_RULE_SEVERITIES), diff --git a/packages/core/src/markdown/lint/types.ts b/packages/core/src/markdown/lint/types.ts index 617f6e5b9..3651bd7eb 100644 --- a/packages/core/src/markdown/lint/types.ts +++ b/packages/core/src/markdown/lint/types.ts @@ -1,3 +1,4 @@ + export const LINT_PLUGIN_IDS = ['markdownlint'] as const; export type LintPluginId = (typeof LINT_PLUGIN_IDS)[number]; @@ -41,6 +42,7 @@ export interface MarkdownlintSlice { rules: Record; } + interface RuleOptionSpecBase { key: string; description: string; diff --git a/packages/core/src/markdown/math-promoter.ts b/packages/core/src/markdown/math-promoter.ts index a5b511f25..bd79424c7 100644 --- a/packages/core/src/markdown/math-promoter.ts +++ b/packages/core/src/markdown/math-promoter.ts @@ -1,3 +1,4 @@ + import type { Code, Root } from 'mdast'; import type { Math as MdastMath } from 'mdast-util-math'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; diff --git a/packages/core/src/markdown/mdast-to-hast-handlers.ts b/packages/core/src/markdown/mdast-to-hast-handlers.ts index 679c72ef7..f84967798 100644 --- a/packages/core/src/markdown/mdast-to-hast-handlers.ts +++ b/packages/core/src/markdown/mdast-to-hast-handlers.ts @@ -1,3 +1,4 @@ + import type { Comment, Element, ElementContent, Properties } from 'hast'; import type { FootnoteDefinition, FootnoteReference } from 'mdast'; import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx'; diff --git a/packages/core/src/markdown/mdast-to-html.ts b/packages/core/src/markdown/mdast-to-html.ts index 8ce2b2492..df788a80d 100644 --- a/packages/core/src/markdown/mdast-to-html.ts +++ b/packages/core/src/markdown/mdast-to-html.ts @@ -1,3 +1,4 @@ + import type { Element, Root as HastRoot, Text as HastText } from 'hast'; import type { Root as MdastRoot, Text as MdastText } from 'mdast'; import type { Handler } from 'mdast-util-to-hast'; diff --git a/packages/core/src/markdown/merged-walker.ts b/packages/core/src/markdown/merged-walker.ts index bfd7e3109..fa3cfcd86 100644 --- a/packages/core/src/markdown/merged-walker.ts +++ b/packages/core/src/markdown/merged-walker.ts @@ -1,3 +1,4 @@ + import type { Nodes, Parent, Root } from 'mdast'; import { SKIP, visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/mermaid-promoter.ts b/packages/core/src/markdown/mermaid-promoter.ts index 1a897fca8..17ed2b829 100644 --- a/packages/core/src/markdown/mermaid-promoter.ts +++ b/packages/core/src/markdown/mermaid-promoter.ts @@ -1,3 +1,4 @@ + import type { Code, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/parse-with-fallback.ts b/packages/core/src/markdown/parse-with-fallback.ts index 9864b3b60..7e4a9a48b 100644 --- a/packages/core/src/markdown/parse-with-fallback.ts +++ b/packages/core/src/markdown/parse-with-fallback.ts @@ -168,6 +168,7 @@ export function parseRecursive( } } + interface VFilePlace { offset?: number; start?: { offset?: number }; @@ -186,6 +187,7 @@ function extractErrorOffset(err: unknown): number | undefined { return undefined; } + interface Region { start: number; end: number; @@ -209,6 +211,7 @@ function nearestBlankLineAfter(src: string, offset: number): number | null { return null; } + export interface TagEvent { kind: 'open' | 'close' | 'self-close'; name: string; @@ -355,6 +358,7 @@ function findFallbackRegion(src: string, errorOffset: number): Region { return { start: blockStart, end: blockEnd }; } + interface SourceBlock { src: string; start: number; @@ -452,6 +456,7 @@ function tryPerBlockFallback( }; } + function wholeDocRawText(source: string): JSONContent { return { type: 'doc', diff --git a/packages/core/src/markdown/parser-drop-closure.ts b/packages/core/src/markdown/parser-drop-closure.ts index e6ab4fd57..43d420187 100644 --- a/packages/core/src/markdown/parser-drop-closure.ts +++ b/packages/core/src/markdown/parser-drop-closure.ts @@ -1,3 +1,4 @@ + export type DroppedTokenAdjudication = | { kind: 'format-dof-axis'; diff --git a/packages/core/src/markdown/pipeline.ts b/packages/core/src/markdown/pipeline.ts index 7f2d5adca..e9e507469 100644 --- a/packages/core/src/markdown/pipeline.ts +++ b/packages/core/src/markdown/pipeline.ts @@ -1,3 +1,4 @@ + import { type FromProseMirrorOptions, fromProseMirror, diff --git a/packages/core/src/markdown/position-aware-join.ts b/packages/core/src/markdown/position-aware-join.ts index 5a5e4c42a..f8f5e8530 100644 --- a/packages/core/src/markdown/position-aware-join.ts +++ b/packages/core/src/markdown/position-aware-join.ts @@ -1,3 +1,4 @@ + import type { Join } from 'mdast-util-to-markdown'; import type { Position } from 'unist'; diff --git a/packages/core/src/markdown/position-slice.ts b/packages/core/src/markdown/position-slice.ts index 0f399d34c..e5b4c9eba 100644 --- a/packages/core/src/markdown/position-slice.ts +++ b/packages/core/src/markdown/position-slice.ts @@ -1,3 +1,4 @@ + import type { Nodes, Root } from 'mdast'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; @@ -476,7 +477,8 @@ export function applyPositionSliceToNode( const first = source[startOff]; if (first !== '[' && first !== '<' && !node.title) { node.data.sourceStyle = 'gfm-autolink'; - } else if (first === '[') { + } + else if (first === '[') { const slice = source.slice(startOff, endOff); const closeBracketIdx = slice.lastIndexOf(']('); if (closeBracketIdx !== -1) { diff --git a/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts b/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts index db10fa54b..ca225ce9b 100644 --- a/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts +++ b/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts @@ -1,3 +1,4 @@ + import type { Comment, Element, Root, Text } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts b/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts index 34068d103..3a2524a9a 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts @@ -1,3 +1,4 @@ + import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts b/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts index 93151d43b..2279a0189 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts @@ -1,3 +1,4 @@ + import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts b/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts index 188f02d83..a0f467c42 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts @@ -1,3 +1,4 @@ + import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts b/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts index 4ba2c3418..4e8542a21 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts @@ -1,3 +1,4 @@ + import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts b/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts index 1a74b1272..a3c96b24a 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts @@ -1,3 +1,4 @@ + import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts b/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts index de35e7b79..e95cf43d6 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts @@ -1,3 +1,4 @@ + import type { Comment, Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts b/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts index b4be1b2ea..ffdb4d5b5 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts @@ -1,3 +1,4 @@ + import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts b/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts index 3c8d88b7c..e2a96d1a7 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts @@ -1,3 +1,4 @@ + import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/resolve-image-url.ts b/packages/core/src/markdown/resolve-image-url.ts index cf2c9da8e..0389575ac 100644 --- a/packages/core/src/markdown/resolve-image-url.ts +++ b/packages/core/src/markdown/resolve-image-url.ts @@ -1,3 +1,4 @@ + import { isRelativeUrl } from './safe-url.ts'; function isDevDiagnosticContext(): boolean { diff --git a/packages/core/src/markdown/safe-url.ts b/packages/core/src/markdown/safe-url.ts index 4d3250d3a..8be632ba4 100644 --- a/packages/core/src/markdown/safe-url.ts +++ b/packages/core/src/markdown/safe-url.ts @@ -1,3 +1,4 @@ + export const SAFE_URL_SCHEMES = ['https', 'http', 'mailto', 'tel', 'ftp', 'sms'] as const; const SCHEME_ALT = SAFE_URL_SCHEMES.map((s) => `${s}:`).join('|'); diff --git a/packages/core/src/markdown/serialize-helpers.ts b/packages/core/src/markdown/serialize-helpers.ts index 46cc6a074..542204403 100644 --- a/packages/core/src/markdown/serialize-helpers.ts +++ b/packages/core/src/markdown/serialize-helpers.ts @@ -1,3 +1,4 @@ + import type { Node as PmNode } from '@tiptap/pm/model'; import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import type { PropDef, SerializeContext } from '../registry/types.ts'; diff --git a/packages/core/src/markdown/single-dollar-math-promoter.ts b/packages/core/src/markdown/single-dollar-math-promoter.ts index be97d4d62..fe73659e6 100644 --- a/packages/core/src/markdown/single-dollar-math-promoter.ts +++ b/packages/core/src/markdown/single-dollar-math-promoter.ts @@ -1,3 +1,4 @@ + import type { PhrasingContent, Root, Text } from 'mdast'; import type { InlineMath } from 'mdast-util-math'; import { SKIP, visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/to-markdown-handlers.ts b/packages/core/src/markdown/to-markdown-handlers.ts index 8f4e9a41c..7bce04302 100644 --- a/packages/core/src/markdown/to-markdown-handlers.ts +++ b/packages/core/src/markdown/to-markdown-handlers.ts @@ -1,3 +1,4 @@ + import type { Nodes, Parents } from 'mdast'; import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import type { Handle, Info, State } from 'mdast-util-to-markdown'; diff --git a/packages/core/src/markdown/unknown-mdast-guard.ts b/packages/core/src/markdown/unknown-mdast-guard.ts index cd245c38c..c5bfd2386 100644 --- a/packages/core/src/markdown/unknown-mdast-guard.ts +++ b/packages/core/src/markdown/unknown-mdast-guard.ts @@ -1,3 +1,4 @@ + import type { Root as MdastRoot } from 'mdast'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/whitespace-char-ref.ts b/packages/core/src/markdown/whitespace-char-ref.ts index d578476a7..ac9f5239b 100644 --- a/packages/core/src/markdown/whitespace-char-ref.ts +++ b/packages/core/src/markdown/whitespace-char-ref.ts @@ -1,3 +1,4 @@ + const INLINE_WHITESPACE_BY_CODE: ReadonlyMap = new Map([ [0x20, ' '], [0x09, '\t'], diff --git a/packages/core/src/markdown/wiki-link-micromark.ts b/packages/core/src/markdown/wiki-link-micromark.ts index 6dad26200..afd9936fe 100644 --- a/packages/core/src/markdown/wiki-link-micromark.ts +++ b/packages/core/src/markdown/wiki-link-micromark.ts @@ -17,6 +17,7 @@ declare module 'micromark-util-types' { } } + const CODE_BANG = 33; // ! const CODE_LBRACKET = 91; // [ const CODE_RBRACKET = 93; // ] @@ -260,6 +261,7 @@ export function wikiLinkSyntax(): Extension { }; } + function enterWikiLink(this: CompileContext, token: Token) { this.enter( { @@ -385,6 +387,7 @@ export const wikiLinkToMarkdown: { unsafe: [{ character: '[', inConstruct: ['phrasing'] }], }; + const MICROMARK_EXT = wikiLinkSyntax(); export function remarkWikiLink(this: Processor) { diff --git a/packages/desktop/src/main/git-spawn-env.test.ts b/packages/desktop/src/main/git-spawn-env.test.ts new file mode 100644 index 000000000..1ffa9c3ba --- /dev/null +++ b/packages/desktop/src/main/git-spawn-env.test.ts @@ -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); + }); +}); diff --git a/packages/desktop/src/main/git-spawn-env.ts b/packages/desktop/src/main/git-spawn-env.ts index d5a9d5987..e212b89bc 100644 --- a/packages/desktop/src/main/git-spawn-env.ts +++ b/packages/desktop/src/main/git-spawn-env.ts @@ -14,8 +14,14 @@ * (`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'; @@ -23,7 +29,7 @@ import { homedir } from 'node:os'; import { delimiter } from 'node:path'; import { augmentGitSpawnPath } from '@inkeep/open-knowledge-core'; -let cached: Record | null = null; +let cachedPath: string | null = null; /** True iff `dir` exists and is a directory (symlinks followed). */ function isDir(dir: string): boolean { @@ -40,18 +46,18 @@ function isDir(dir: string): boolean { * share-fetch arm's `GIT_TERMINAL_PROMPT=0`). */ export function gitSpawnEnv(): Record { - 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, + }; } diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 3c2f2819c..dfebdb5a5 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -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'; @@ -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 & { 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 @@ -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 diff --git a/packages/desktop/src/main/path-install.ts b/packages/desktop/src/main/path-install.ts index 1ec5bc4d4..0d507bf2b 100644 --- a/packages/desktop/src/main/path-install.ts +++ b/packages/desktop/src/main/path-install.ts @@ -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 }, diff --git a/packages/desktop/src/main/shell-env.test.ts b/packages/desktop/src/main/shell-env.test.ts new file mode 100644 index 000000000..892bb7b82 --- /dev/null +++ b/packages/desktop/src/main/shell-env.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest'; +import { applyHarvestedAuthSock, harvestShellAuthSock, type ShellEnvLogger } from './shell-env.ts'; + +const MARK = '<>'; + +function collectingLogger(): { + logger: ShellEnvLogger; + events: string[]; + payloads: Array & { event: string }>; +} { + const events: string[] = []; + const payloads: Array & { event: string }> = []; + return { + logger: { + event: (payload) => { + events.push(payload.event); + payloads.push(payload); + }, + }, + events, + payloads, + }; +} + +function fakeSpawn(result: { + code: number | null; + stdout: string; + stderr?: string; + timedOut?: boolean; +}) { + const calls: Array<{ command: string; args: string[] }> = []; + const spawn = async (command: string, args: string[]) => { + calls.push({ command, args }); + return { stderr: '', ...result }; + }; + return { spawn, calls }; +} + +describe('harvestShellAuthSock', () => { + it('returns the sock wrapped in markers', async () => { + const { spawn } = fakeSpawn({ code: 0, stdout: `${MARK}/tmp/agent.sock${MARK}` }); + const sock = await harvestShellAuthSock({ + env: { SHELL: '/bin/zsh' }, + platform: 'darwin', + spawn, + }); + expect(sock).toBe('/tmp/agent.sock'); + }); + + it('extracts the sock despite rc-file noise around the markers', async () => { + const { spawn } = fakeSpawn({ + code: 0, + stdout: `Welcome!\nnvm loaded\n${MARK}/tmp/agent.sock${MARK}\ntrailing noise`, + }); + const sock = await harvestShellAuthSock({ + env: { SHELL: '/bin/zsh' }, + platform: 'darwin', + spawn, + }); + expect(sock).toBe('/tmp/agent.sock'); + }); + + it('returns null when the login shell has no SSH_AUTH_SOCK', async () => { + const { spawn } = fakeSpawn({ code: 0, stdout: `${MARK}${MARK}` }); + expect( + await harvestShellAuthSock({ env: { SHELL: '/bin/zsh' }, platform: 'darwin', spawn }), + ).toBeNull(); + }); + + it('returns null and logs on non-zero exit, carrying bounded stderr', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawn = async (command: string, args: string[]) => { + calls.push({ command, args }); + return { code: 1, stdout: '', stderr: `zsh: bad substitution${'x'.repeat(400)}` }; + }; + const { logger, events, payloads } = collectingLogger(); + expect( + await harvestShellAuthSock({ + env: { SHELL: '/bin/zsh' }, + platform: 'darwin', + spawn, + logger, + }), + ).toBeNull(); + expect(events).toContain('shell-authsock-harvest-failed'); + const failure = payloads.find((p) => p.event === 'shell-authsock-harvest-failed'); + expect(failure?.stderr).toMatch(/^zsh: bad substitution/); + expect((failure?.stderr as string).length).toBeLessThanOrEqual(300); + }); + + it('returns null and logs on timeout', async () => { + const { spawn } = fakeSpawn({ code: null, stdout: '', timedOut: true }); + const { logger, events } = collectingLogger(); + expect( + await harvestShellAuthSock({ + env: { SHELL: '/bin/zsh' }, + platform: 'darwin', + spawn, + logger, + }), + ).toBeNull(); + expect(events).toContain('shell-authsock-harvest-failed'); + }); + + it('returns null and logs bounded stdout when the markers are missing', async () => { + const { spawn } = fakeSpawn({ code: 0, stdout: `rc noise only, no marker${'y'.repeat(400)}` }); + const { logger, events, payloads } = collectingLogger(); + expect( + await harvestShellAuthSock({ + env: { SHELL: '/bin/zsh' }, + platform: 'darwin', + spawn, + logger, + }), + ).toBeNull(); + expect(events).toContain('shell-authsock-harvest-failed'); + const failure = payloads.find((p) => p.event === 'shell-authsock-harvest-failed'); + expect(failure?.reason).toBe('marker-missing'); + expect(failure?.stdout).toMatch(/^rc noise only/); + expect((failure?.stdout as string).length).toBeLessThanOrEqual(300); + }); + + it('returns null and logs when spawn throws', async () => { + const { logger, events } = collectingLogger(); + expect( + await harvestShellAuthSock({ + env: { SHELL: '/bin/zsh' }, + platform: 'darwin', + spawn: async () => { + throw new Error('ENOENT'); + }, + logger, + }), + ).toBeNull(); + expect(events).toContain('shell-authsock-harvest-failed'); + }); + + it('skips win32 without spawning', async () => { + const { spawn, calls } = fakeSpawn({ code: 0, stdout: `${MARK}/tmp/x${MARK}` }); + expect(await harvestShellAuthSock({ env: {}, platform: 'win32', spawn })).toBeNull(); + expect(calls).toHaveLength(0); + }); + + it('spawns $SHELL as an interactive login shell', async () => { + const { spawn, calls } = fakeSpawn({ code: 0, stdout: `${MARK}/tmp/x${MARK}` }); + await harvestShellAuthSock({ + env: { SHELL: '/opt/homebrew/bin/fish' }, + platform: 'darwin', + spawn, + }); + expect(calls[0]?.command).toBe('/opt/homebrew/bin/fish'); + expect(calls[0]?.args[0]).toBe('-ilc'); + }); + + it('falls back to zsh on darwin and bash on linux when SHELL is unset', async () => { + const darwin = fakeSpawn({ code: 0, stdout: `${MARK}/tmp/x${MARK}` }); + await harvestShellAuthSock({ env: {}, platform: 'darwin', spawn: darwin.spawn }); + expect(darwin.calls[0]?.command).toBe('/bin/zsh'); + + const linux = fakeSpawn({ code: 0, stdout: `${MARK}/tmp/x${MARK}` }); + await harvestShellAuthSock({ env: {}, platform: 'linux', spawn: linux.spawn }); + expect(linux.calls[0]?.command).toBe('/bin/bash'); + }); +}); + +describe('applyHarvestedAuthSock', () => { + it('patches env when the harvested sock differs, logging previous and new values', () => { + const env: Record = { SSH_AUTH_SOCK: '/launchd/Listeners' }; + const { logger, events, payloads } = collectingLogger(); + expect(applyHarvestedAuthSock(env, '/tmp/agent.sock', logger)).toBe(true); + expect(env.SSH_AUTH_SOCK).toBe('/tmp/agent.sock'); + expect(events).toContain('shell-authsock-harvested'); + const harvested = payloads.find((p) => p.event === 'shell-authsock-harvested'); + expect(harvested?.from).toBe('/launchd/Listeners'); + expect(harvested?.to).toBe('/tmp/agent.sock'); + }); + + it('logs from: null when no prior sock existed', () => { + const env: Record = {}; + const { logger, payloads } = collectingLogger(); + expect(applyHarvestedAuthSock(env, '/tmp/agent.sock', logger)).toBe(true); + const harvested = payloads.find((p) => p.event === 'shell-authsock-harvested'); + expect(harvested?.from).toBeNull(); + expect(harvested?.to).toBe('/tmp/agent.sock'); + }); + + it('sets env when no sock was present at all', () => { + const env: Record = {}; + expect(applyHarvestedAuthSock(env, '/tmp/agent.sock', collectingLogger().logger)).toBe(true); + expect(env.SSH_AUTH_SOCK).toBe('/tmp/agent.sock'); + }); + + it('no-ops when the harvested sock matches the current value', () => { + const env: Record = { SSH_AUTH_SOCK: '/tmp/agent.sock' }; + expect(applyHarvestedAuthSock(env, '/tmp/agent.sock', collectingLogger().logger)).toBe(false); + expect(env.SSH_AUTH_SOCK).toBe('/tmp/agent.sock'); + }); + + it('never downgrades: null harvest leaves the existing value untouched', () => { + const env: Record = { SSH_AUTH_SOCK: '/launchd/Listeners' }; + expect(applyHarvestedAuthSock(env, null, collectingLogger().logger)).toBe(false); + expect(env.SSH_AUTH_SOCK).toBe('/launchd/Listeners'); + }); + + it('never downgrades: empty-string harvest leaves the existing value untouched', () => { + const env: Record = { SSH_AUTH_SOCK: '/launchd/Listeners' }; + expect(applyHarvestedAuthSock(env, '', collectingLogger().logger)).toBe(false); + expect(env.SSH_AUTH_SOCK).toBe('/launchd/Listeners'); + }); +}); diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts new file mode 100644 index 000000000..88bd066a7 --- /dev/null +++ b/packages/desktop/src/main/shell-env.ts @@ -0,0 +1,122 @@ +/** + * Login-shell `SSH_AUTH_SOCK` harvest for GUI launches. + * + * Finder/Dock-launched Electron inherits launchd's environment, whose + * `SSH_AUTH_SOCK` points at Apple's default ssh-agent — an agent that holds + * no keys when the user's keys live in an external agent (1Password, Proton + * Pass, custom `ssh-agent`) exported via `export SSH_AUTH_SOCK=...` in a + * shell rc file. GUI apps never read rc files, so every git-over-SSH spawn + * downstream (utility-process server, detached server, desktop-main git) + * authenticates against the wrong agent and fails with + * `Permission denied (publickey)` while the same command works in a + * terminal. Same launchd-impoverishment disease as the PATH handling in + * `git-spawn-env.ts` / `path-install.ts`, for a different variable. + * + * The remedy mirrors `discoverRealInteractivePath`: spawn one interactive + * login shell, capture the variable, and patch `process.env` before the + * first consumer reads it. Deliberately scoped to `SSH_AUTH_SOCK` only — + * harvesting more (PATH especially) has a much larger blast radius and its + * own established mechanisms. + */ + +import { defaultSpawn } from './path-install.ts'; + +export interface ShellEnvLogger { + event: (payload: Record & { event: string }) => void; +} + +const DEFAULT_LOGGER: ShellEnvLogger = { + event: (payload) => console.info('[shell-env]', payload), +}; + +/** + * Sentinel wrapping the captured value so rc-file noise (echoes, motd, + * profiling output) on stdout cannot corrupt it — unlike PATH discovery, + * which tolerates junk entries, a socket path must come back byte-exact. + */ +const MARK = '<>'; + +export interface HarvestShellAuthSockOpts { + env?: Record; + platform?: string; + spawn?: typeof defaultSpawn; + logger?: ShellEnvLogger; + timeoutMs?: number; +} + +/** + * Returns the login shell's `SSH_AUTH_SOCK`, or `null` on any failure + * (timeout, non-zero exit, empty value, spawn error). Callers must treat + * `null` as "leave the current value alone" — see `applyHarvestedAuthSock`. + * + * Skipped entirely on win32: Windows agents talk over a fixed named pipe, + * not an env-var-addressed socket. + */ +export async function harvestShellAuthSock( + opts: HarvestShellAuthSockOpts = {}, +): Promise { + const platform = opts.platform ?? process.platform; + if (platform === 'win32') return null; + const env = opts.env ?? process.env; + const shell = env.SHELL ?? (platform === 'linux' ? '/bin/bash' : '/bin/zsh'); + const spawn = opts.spawn ?? defaultSpawn; + const logger = opts.logger ?? DEFAULT_LOGGER; + try { + const result = await spawn(shell, ['-ilc', `printf %s "${MARK}$SSH_AUTH_SOCK${MARK}"`], { + timeoutMs: opts.timeoutMs ?? 2000, + env, + }); + if (result.code !== 0 || result.timedOut) { + logger.event({ + event: 'shell-authsock-harvest-failed', + shell, + code: result.code, + timedOut: result.timedOut ?? false, + // Bounded: a broken rc file can produce unbounded stderr. + stderr: result.stderr.slice(0, 300), + }); + return null; + } + const first = result.stdout.indexOf(MARK); + const last = result.stdout.lastIndexOf(MARK); + if (first === -1 || last <= first) { + logger.event({ + event: 'shell-authsock-harvest-failed', + shell, + reason: 'marker-missing', + // Bounded: the raw capture is the only clue to why markers are absent. + stdout: result.stdout.slice(0, 300), + }); + return null; + } + const value = result.stdout.slice(first + MARK.length, last).trim(); + return value === '' ? null : value; + } catch (err) { + logger.event({ + event: 'shell-authsock-harvest-failed', + shell, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * Patches `env.SSH_AUTH_SOCK` with the harvested value. Never downgrades: + * a `null`/empty harvest or an unchanged value leaves `env` untouched, so a + * hung rc file or a genuinely agent-less login shell can't strip a working + * socket from a terminal-launched process. Returns true iff `env` changed. + */ +export function applyHarvestedAuthSock( + env: Record, + harvested: string | null, + logger: ShellEnvLogger = DEFAULT_LOGGER, +): boolean { + if (harvested === null || harvested === '' || harvested === env.SSH_AUTH_SOCK) { + return false; + } + const previous = env.SSH_AUTH_SOCK ?? null; + env.SSH_AUTH_SOCK = harvested; + logger.event({ event: 'shell-authsock-harvested', from: previous, to: harvested }); + return true; +} diff --git a/packages/desktop/src/main/worktree-recents.ts b/packages/desktop/src/main/worktree-recents.ts index 35f41cc28..9e60441db 100644 --- a/packages/desktop/src/main/worktree-recents.ts +++ b/packages/desktop/src/main/worktree-recents.ts @@ -24,8 +24,6 @@ import { basename, dirname, isAbsolute, resolve } from 'node:path'; import { promisify } from 'node:util'; import { gitSpawnEnv } from './git-spawn-env.ts'; -const GIT_ENV = gitSpawnEnv(); - const execFileAsync = promisify(execFile); export interface RecentGitInfo { @@ -61,7 +59,7 @@ export function readWorktreeBranch(projectPath: string): string | null { const out = String( execFileSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: projectPath, - env: GIT_ENV, + env: gitSpawnEnv(), }), ).trim(); return out.length > 0 ? out : null; @@ -84,7 +82,7 @@ export async function readWorktreeBranchAsync(projectPath: string): Promise 0 ? out : null; @@ -145,7 +143,7 @@ const REV_PARSE_ARGS = [ function computeRecentGit(realPath: string): RecentGitInfo { let out: string; try { - out = String(execFileSync('git', [...REV_PARSE_ARGS], { cwd: realPath, env: GIT_ENV })); + out = String(execFileSync('git', [...REV_PARSE_ARGS], { cwd: realPath, env: gitSpawnEnv() })); } catch { return EMPTY; } @@ -157,7 +155,7 @@ async function computeRecentGitAsync(realPath: string): Promise { try { const { stdout } = await execFileAsync('git', [...REV_PARSE_ARGS], { cwd: realPath, - env: GIT_ENV, + env: gitSpawnEnv(), }); out = stdout; } catch { diff --git a/packages/desktop/src/main/worktree-service.ts b/packages/desktop/src/main/worktree-service.ts index 170df6771..f4af43856 100644 --- a/packages/desktop/src/main/worktree-service.ts +++ b/packages/desktop/src/main/worktree-service.ts @@ -46,14 +46,15 @@ import { seedWorktreeProjectSetup } from './worktree-setup-inherit.ts'; const execFileAsync = promisify(execFile); -/** English-stable, PATH-augmented git env — see `git-spawn-env.ts`. */ -const GIT_ENV = gitSpawnEnv(); - -/** Fetch spawn env: `GIT_ENV` plus `GIT_TERMINAL_PROMPT=0`, mirroring the - * server's git discipline — desktop main has no terminal to answer a +/** Fetch spawn env: `gitSpawnEnv()` plus `GIT_TERMINAL_PROMPT=0`, mirroring + * the server's git discipline — desktop main has no terminal to answer a * credential prompt, so a credentialed remote must fail fast (into the - * `fetch-failed` arm) instead of stalling until the timeout kill. */ -const FETCH_GIT_ENV = { ...GIT_ENV, GIT_TERMINAL_PROMPT: '0' } as const; + * `fetch-failed` arm) instead of stalling until the timeout kill. Built per + * call, never frozen at module level: `gitSpawnEnv()` must reflect the + * startup `SSH_AUTH_SOCK` harvest (see `git-spawn-env.ts`). */ +function fetchGitEnv(): Record { + return { ...gitSpawnEnv(), GIT_TERMINAL_PROMPT: '0' }; +} /** Default bound for the share-checkout fetch — matches the server's * fast-forward fetch bound so a stalled network degrades to a typed @@ -171,7 +172,7 @@ export async function createWorktree(args: CreateWorktreeArgs): Promise { try { await execFileAsync('git', ['show-ref', '--verify', '--quiet', ref], { cwd: anchorPath, - env: GIT_ENV, + env: gitSpawnEnv(), timeout: 5_000, }); return true; @@ -320,7 +321,7 @@ async function fetchShareBranch( try { await execFileAsync('git', ['fetch', 'origin', branch], { cwd: anchorPath, - env: FETCH_GIT_ENV, + env: fetchGitEnv(), timeout: timeoutMs, }); return null; @@ -392,7 +393,7 @@ async function listLocalBranches(anchorPath: string): Promise { const { stdout } = await execFileAsync( 'git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], - { cwd: anchorPath, env: GIT_ENV }, + { cwd: anchorPath, env: gitSpawnEnv() }, ); return parseBranchList(String(stdout)); } catch { @@ -415,7 +416,7 @@ async function listRemoteBranches(anchorPath: string): Promise { const { stdout } = await execFileAsync( 'git', ['for-each-ref', '--format=%(refname:short)', 'refs/remotes/'], - { cwd: anchorPath, env: GIT_ENV }, + { cwd: anchorPath, env: gitSpawnEnv() }, ); // `refname:short` renders a `/HEAD` pointer as `` (no // trailing `/HEAD`) — drop any ref with no slash (a bare remote name), plus @@ -455,7 +456,7 @@ async function computeBehindCounts( const { stdout } = await execFileAsync( 'git', ['rev-list', '--count', `${branch}..${upstream}`], - { cwd: anchorPath, env: GIT_ENV }, + { cwd: anchorPath, env: gitSpawnEnv() }, ); const n = Number.parseInt(String(stdout).trim(), 10); if (Number.isFinite(n) && n >= 0) out[branch] = n; @@ -506,7 +507,7 @@ function ensureWorktreesExcluded(anchorPath: string): void { /** Synchronous git read that returns the trimmed stdout, or null on failure. */ function execFileSyncTrim(cmd: string, cmdArgs: string[], cwd: string): string | null { try { - return String(execFileSync(cmd, cmdArgs, { cwd, env: GIT_ENV })).trim(); + return String(execFileSync(cmd, cmdArgs, { cwd, env: gitSpawnEnv() })).trim(); } catch { return null; } From 19433c78ea0fbebc956e26d39722657144a03217 Mon Sep 17 00:00:00 2001 From: omar-inkeep Date: Wed, 22 Jul 2026 12:44:21 -0400 Subject: [PATCH 2/2] fix(open-knowledge): keep sync alive for SSH origins without a token (#2834) * fix(server): keep sync alive for SSH origins with no GitHub token The push-permission probe treated a missing gh/OK token as proof the push would fail and paused sync with a sign-in prompt. That inference only holds for HTTPS origins, where git authenticates with a token. SSH and git protocol origins authenticate with SSH keys the probe cannot see, so self-hosted forge users (Gitea, Forgejo) and github.com-over-SSH users with no gh CLI had sync silently disabled despite pushing fine. Key the anonymous short-circuit on the origin transport instead of the host: HTTPS keeps the signed-out denial and its sign-in affordance (including GHES), while ssh, scp-style, and git origins now abstain with a new unknown ssh-unverified result. Unknown is already the lenient posture end to end, so sync proceeds and a genuinely unauthorized push surfaces through the existing auth-error path with an accurate SSH message. The parser now reports which URL form matched (transport), and the wire schema carries the new code. No UI changes needed. * review: derive wire unions from PushPermission, pin ssh-unverified UI predicate Derive the deniedReason and unknownError payload types in sync-engine's PushPermissionStatus structurally from the source-of-truth PushPermission union, so a code added in github-permissions.ts can no longer drift from the wire shape unnoticed. The Zod enum in core still needs its manual twin update; the schema round-trip test covers that half at runtime. Also pin shouldOfferSignInAgain to false for unknown/ssh-unverified so a future broadening of the predicate cannot quietly resurrect the misleading sign-in affordance for SSH-origin users. * style: apply biome formatting to sign-in predicate test GitOrigin-RevId: 1b25773ba684dd932c1dba52e3734ed78727e548 --- .changeset/ssh-origin-probe-leniency.md | 15 ++++ knip.config.ts | 4 +- .../components/SyncStatusBadge.dom.test.tsx | 7 ++ .../core/src/bridge/bind-frontmatter-doc.ts | 1 - packages/core/src/bridge/bridge-invariant.ts | 1 - .../core/src/bridge/doc-boundary-space.ts | 1 - packages/core/src/bridge/growth-detect.ts | 1 - packages/core/src/bridge/merge-three-way.ts | 1 - packages/core/src/bridge/normalize.ts | 1 - .../src/bridge/pm-structural-equivalence.ts | 3 - packages/core/src/bridge/subsequence.ts | 1 - .../core/src/markdown/callout-transformer.ts | 1 - .../core/src/markdown/comment-promoter.ts | 1 - .../src/markdown/dedent-block-jsx-close.ts | 1 - .../markdown/details-accordion-promoter.ts | 1 - packages/core/src/markdown/fence-regions.ts | 1 - packages/core/src/markdown/fixtures/index.ts | 6 -- .../src/markdown/fixtures/perf/generate.ts | 5 -- .../src/markdown/guard-flanking-matrix.ts | 1 - .../core/src/markdown/handler-shadow-audit.ts | 1 - .../core/src/markdown/highlight-promoter.ts | 1 - packages/core/src/markdown/html-to-mdast.ts | 4 +- packages/core/src/markdown/image-promoter.ts | 1 - packages/core/src/markdown/index.ts | 9 --- .../core/src/markdown/lint/config-files.ts | 1 - .../core/src/markdown/lint/config-schemas.ts | 1 - .../core/src/markdown/lint/default-config.ts | 1 - packages/core/src/markdown/lint/index.ts | 1 - packages/core/src/markdown/lint/plugins.ts | 2 - packages/core/src/markdown/lint/types.ts | 2 - packages/core/src/markdown/math-promoter.ts | 1 - .../src/markdown/mdast-to-hast-handlers.ts | 1 - packages/core/src/markdown/mdast-to-html.ts | 1 - packages/core/src/markdown/merged-walker.ts | 1 - .../core/src/markdown/mermaid-promoter.ts | 1 - .../core/src/markdown/parse-with-fallback.ts | 5 -- .../core/src/markdown/parser-drop-closure.ts | 1 - packages/core/src/markdown/pipeline.ts | 1 - .../core/src/markdown/position-aware-join.ts | 1 - packages/core/src/markdown/position-slice.ts | 4 +- .../rehype-plugins/skip-notion-whitespace.ts | 1 - .../rehype-plugins/strip-cocoa-meta.ts | 1 - .../rehype-plugins/strip-gdocs-wrapper.ts | 1 - .../rehype-plugins/strip-github-hovercard.ts | 1 - .../rehype-plugins/strip-gmail-classes.ts | 1 - .../rehype-plugins/strip-gsheets-wrapper.ts | 1 - .../rehype-plugins/strip-mso-styles.ts | 1 - .../rehype-plugins/strip-slack-classes.ts | 1 - .../rehype-plugins/strip-vscode-spans.ts | 1 - .../core/src/markdown/resolve-image-url.ts | 1 - packages/core/src/markdown/safe-url.ts | 1 - .../core/src/markdown/serialize-helpers.ts | 1 - .../markdown/single-dollar-math-promoter.ts | 1 - .../core/src/markdown/to-markdown-handlers.ts | 1 - .../core/src/markdown/unknown-mdast-guard.ts | 1 - .../core/src/markdown/whitespace-char-ref.ts | 1 - .../core/src/markdown/wiki-link-micromark.ts | 3 - .../core/src/schemas/api/sync-seed.test.ts | 27 +++++++ packages/core/src/schemas/api/sync-seed.ts | 9 ++- .../server/src/github-permissions.test.ts | 78 +++++++++++++++++++ packages/server/src/github-permissions.ts | 39 +++++++++- packages/server/src/share/git-context.test.ts | 28 +++++++ packages/server/src/share/git-context.ts | 33 +++++--- packages/server/src/sync-engine.test.ts | 33 ++++++++ packages/server/src/sync-engine.ts | 14 ++-- 65 files changed, 267 insertions(+), 107 deletions(-) create mode 100644 .changeset/ssh-origin-probe-leniency.md diff --git a/.changeset/ssh-origin-probe-leniency.md b/.changeset/ssh-origin-probe-leniency.md new file mode 100644 index 000000000..2ff12181a --- /dev/null +++ b/.changeset/ssh-origin-probe-leniency.md @@ -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. diff --git a/knip.config.ts b/knip.config.ts index 6e3847861..296751a02 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -121,9 +121,7 @@ export default { 'packages/server': { entry: ['src/**/*.test.ts', 'src/parse-worker.ts'], project: 'src/**', - ignoreDependencies: [ - '@types/shell-quote', - ], + ignoreDependencies: ['@types/shell-quote'], }, 'packages/cli': { entry: ['src/**/*.test.ts', 'scripts/*.ts', 'tests/**/*.ts', 'src/parse-worker.ts'], diff --git a/packages/app/src/components/SyncStatusBadge.dom.test.tsx b/packages/app/src/components/SyncStatusBadge.dom.test.tsx index 8bd58e5ce..33fbc98fb 100644 --- a/packages/app/src/components/SyncStatusBadge.dom.test.tsx +++ b/packages/app/src/components/SyncStatusBadge.dom.test.tsx @@ -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); }); }); diff --git a/packages/core/src/bridge/bind-frontmatter-doc.ts b/packages/core/src/bridge/bind-frontmatter-doc.ts index 8f9d95287..c014b1413 100644 --- a/packages/core/src/bridge/bind-frontmatter-doc.ts +++ b/packages/core/src/bridge/bind-frontmatter-doc.ts @@ -1,4 +1,3 @@ - import type * as Y from 'yjs'; import type { Err, Ok, Result } from '../config/result.ts'; import { type FrontmatterValidationError, toFrontmatterIssue } from '../frontmatter/errors.ts'; diff --git a/packages/core/src/bridge/bridge-invariant.ts b/packages/core/src/bridge/bridge-invariant.ts index 8bfdb40cd..005627195 100644 --- a/packages/core/src/bridge/bridge-invariant.ts +++ b/packages/core/src/bridge/bridge-invariant.ts @@ -1,4 +1,3 @@ - import { fnv1aDigest } from './hash-util.ts'; export type BridgeInvariantSite = 'observer-b' | 'persistence' | 'test-harness'; diff --git a/packages/core/src/bridge/doc-boundary-space.ts b/packages/core/src/bridge/doc-boundary-space.ts index c18c086d5..8890f6739 100644 --- a/packages/core/src/bridge/doc-boundary-space.ts +++ b/packages/core/src/bridge/doc-boundary-space.ts @@ -1,4 +1,3 @@ - import { FRONTMATTER_RE, stripFrontmatter } from '../extensions/frontmatter.ts'; const LEADING_BOUNDARY_RE = /^(?:\r?\n)+/; diff --git a/packages/core/src/bridge/growth-detect.ts b/packages/core/src/bridge/growth-detect.ts index 463e89595..9d9c244b0 100644 --- a/packages/core/src/bridge/growth-detect.ts +++ b/packages/core/src/bridge/growth-detect.ts @@ -1,4 +1,3 @@ - const DEFAULT_MIN_SUBSTANTIVE_LINE_LENGTH = 16; export const DUPLICATION_GATE_MIN_LINE_LENGTH = 8; diff --git a/packages/core/src/bridge/merge-three-way.ts b/packages/core/src/bridge/merge-three-way.ts index 3c927eb47..3ab9cc6ef 100644 --- a/packages/core/src/bridge/merge-three-way.ts +++ b/packages/core/src/bridge/merge-three-way.ts @@ -38,7 +38,6 @@ function mergeThreeWayImpl(baseline: string, userText: string, agentText: string return parts.join('\n'); } - export type BridgeMergeContentLossSide = 'user' | 'agent'; export type BridgeMergeContentLossWhich = 'substring' | 'order' | 'growth'; diff --git a/packages/core/src/bridge/normalize.ts b/packages/core/src/bridge/normalize.ts index 3f0880aa5..4b8890476 100644 --- a/packages/core/src/bridge/normalize.ts +++ b/packages/core/src/bridge/normalize.ts @@ -1,4 +1,3 @@ - const COMMONMARK_ESCAPE_RE = /\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~])/g; const TABLE_ALIGN_ROW_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/; diff --git a/packages/core/src/bridge/pm-structural-equivalence.ts b/packages/core/src/bridge/pm-structural-equivalence.ts index 0be5203e4..754dc5cc2 100644 --- a/packages/core/src/bridge/pm-structural-equivalence.ts +++ b/packages/core/src/bridge/pm-structural-equivalence.ts @@ -32,7 +32,6 @@ const BR_SENTINEL: PmStructuralNode = { type: '__br__' }; const BR_LITERAL_RE = /^$/i; - interface DegradeEntry { readonly label: string; readonly severity: ToleranceClassSeverity; @@ -128,7 +127,6 @@ export interface ComparePmStructuralOptions { ignoreAttrs?: (attrKey: string) => boolean; } - /** Rebuild a node with `fn` applied to each child; identity when childless. * Shared by the degrade canonicalizers so none re-implements the walk. */ function mapChildren( @@ -250,7 +248,6 @@ function applyDegrades(tree: PmStructuralNode): { return { normalized: current, fired }; } - export function comparePmStructural( expected: PmStructuralNode, actual: PmStructuralNode, diff --git a/packages/core/src/bridge/subsequence.ts b/packages/core/src/bridge/subsequence.ts index dca644141..af20de1e2 100644 --- a/packages/core/src/bridge/subsequence.ts +++ b/packages/core/src/bridge/subsequence.ts @@ -1,4 +1,3 @@ - /** True when `needle` is a subsequence of `haystack` (two-pointer, O(n+m)): * insertions in `haystack` are free; any dropped or substituted `needle` byte * fails. */ diff --git a/packages/core/src/markdown/callout-transformer.ts b/packages/core/src/markdown/callout-transformer.ts index a363dfd54..83d47404a 100644 --- a/packages/core/src/markdown/callout-transformer.ts +++ b/packages/core/src/markdown/callout-transformer.ts @@ -1,4 +1,3 @@ - import type { Blockquote, Paragraph, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/comment-promoter.ts b/packages/core/src/markdown/comment-promoter.ts index 47f0d07e4..193e286b0 100644 --- a/packages/core/src/markdown/comment-promoter.ts +++ b/packages/core/src/markdown/comment-promoter.ts @@ -1,4 +1,3 @@ - import type { Nodes, Paragraph, PhrasingContent, Root, RootContent, Text } from 'mdast'; import { SKIP, visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/dedent-block-jsx-close.ts b/packages/core/src/markdown/dedent-block-jsx-close.ts index b6299a948..ece5c02a1 100644 --- a/packages/core/src/markdown/dedent-block-jsx-close.ts +++ b/packages/core/src/markdown/dedent-block-jsx-close.ts @@ -1,4 +1,3 @@ - import { findFencedRegions, isInsideFence } from './fence-regions.ts'; const INDENTED_BLOCK_JSX_CLOSE_RE = /^([ ]{1,3})(<\/[A-Z][A-Za-z0-9_]*\s*>)([ \t]*)$/gm; diff --git a/packages/core/src/markdown/details-accordion-promoter.ts b/packages/core/src/markdown/details-accordion-promoter.ts index acd398aa6..54774764a 100644 --- a/packages/core/src/markdown/details-accordion-promoter.ts +++ b/packages/core/src/markdown/details-accordion-promoter.ts @@ -1,4 +1,3 @@ - import type { Nodes, Paragraph, Parent, Root, Text } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/fence-regions.ts b/packages/core/src/markdown/fence-regions.ts index fb5ac8e8a..c7518d614 100644 --- a/packages/core/src/markdown/fence-regions.ts +++ b/packages/core/src/markdown/fence-regions.ts @@ -1,4 +1,3 @@ - const FENCE_RE = /^(`{3,}|~{3,})/gm; export function findFencedRegions(src: string): Array<[number, number]> { diff --git a/packages/core/src/markdown/fixtures/index.ts b/packages/core/src/markdown/fixtures/index.ts index 6931623c7..00f5771ee 100644 --- a/packages/core/src/markdown/fixtures/index.ts +++ b/packages/core/src/markdown/fixtures/index.ts @@ -1,4 +1,3 @@ - import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,7 +8,6 @@ function fixturePath(...segments: string[]): string { return resolve(FIXTURES_DIR, ...segments); } - interface GfmExample { section: string; markdown: string; @@ -19,7 +17,6 @@ export function loadGfmExamples(): GfmExample[] { return JSON.parse(readFileSync(fixturePath('gfm', 'examples.json'), 'utf8')) as GfmExample[]; } - interface MdxCrashEntry { id: string; input: string; @@ -71,7 +68,6 @@ export function loadLargeEmbedFixtures(): LargeEmbedFixture[] { ) as LargeEmbedFixture[]; } - export function loadPrd6955Before(): string { return readFileSync(fixturePath('regression', 'prd-6955-before.md'), 'utf8'); } @@ -80,7 +76,6 @@ export function loadPrd6955CorruptedTriplicated(): string { return readFileSync(fixturePath('regression', 'prd-6955-corrupted-triplicated.md'), 'utf8'); } - export interface NgPinnedCase { id: string; name: string; @@ -97,7 +92,6 @@ export function loadNgPinnedCases(): NgPinnedCase[] { ) as NgPinnedCase[]; } - export function loadLargeRealistic(): string { return readFileSync(fixturePath('perf', 'large-realistic.md'), 'utf8'); } diff --git a/packages/core/src/markdown/fixtures/perf/generate.ts b/packages/core/src/markdown/fixtures/perf/generate.ts index daa3ef82b..8b1eb8519 100644 --- a/packages/core/src/markdown/fixtures/perf/generate.ts +++ b/packages/core/src/markdown/fixtures/perf/generate.ts @@ -1,4 +1,3 @@ - import { writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -11,7 +10,6 @@ const BASELINE_ONLY_COUNTS = [500, 2500] as const; const SEED = 0xf1de1117; - function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { @@ -38,7 +36,6 @@ function pickWeighted(rand: () => number, items: readonly [T, number][]): T { return items[items.length - 1][0]; } - const WORDS = [ 'lorem', 'ipsum', @@ -156,7 +153,6 @@ function mdxBlock(rand: () => number): string { return `<${name}>\n\n${sentence(rand)}\n\n`; } - type BlockKind = 'paragraph' | 'heading' | 'list' | 'code' | 'table' | 'mdx'; const MIX: readonly [BlockKind, number][] = [ @@ -195,7 +191,6 @@ function generateDocument(blockCount: number, seed: number): string { return `${blocks.join('\n\n')}\n`; } - function main(): void { for (const count of [...BLOCK_COUNTS, ...BASELINE_ONLY_COUNTS]) { const doc = generateDocument(count, SEED); diff --git a/packages/core/src/markdown/guard-flanking-matrix.ts b/packages/core/src/markdown/guard-flanking-matrix.ts index 32c055c5b..420dc0fe5 100644 --- a/packages/core/src/markdown/guard-flanking-matrix.ts +++ b/packages/core/src/markdown/guard-flanking-matrix.ts @@ -7,7 +7,6 @@ import { import { BACKSLASH_GUARD_SUBSTITUTIONS, encodeBackslashEscapes } from './backslash-escape-guard.ts'; import { ENTITY_REF_GUARD_SUBSTITUTIONS, encodeEntityRefs } from './entity-ref-guard.ts'; - export const ATTENTION_DELIMITERS = ['*', '_', '**', '~~', '=='] as const; export type FlankClass = 'whitespace' | 'punctuation' | 'other'; diff --git a/packages/core/src/markdown/handler-shadow-audit.ts b/packages/core/src/markdown/handler-shadow-audit.ts index 283913255..2653473da 100644 --- a/packages/core/src/markdown/handler-shadow-audit.ts +++ b/packages/core/src/markdown/handler-shadow-audit.ts @@ -1,4 +1,3 @@ - export interface HandlerShadowWitness { input: string; expect: 'byte' | 'byte-and-reparse-type'; diff --git a/packages/core/src/markdown/highlight-promoter.ts b/packages/core/src/markdown/highlight-promoter.ts index 6319f264f..f55912407 100644 --- a/packages/core/src/markdown/highlight-promoter.ts +++ b/packages/core/src/markdown/highlight-promoter.ts @@ -1,4 +1,3 @@ - import type { Nodes, Parent, PhrasingContent, Root, Text } from 'mdast'; import type { MdxJsxTextElement } from 'mdast-util-mdx'; import { SKIP, visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/html-to-mdast.ts b/packages/core/src/markdown/html-to-mdast.ts index 8dc3a81b5..b1f252c31 100644 --- a/packages/core/src/markdown/html-to-mdast.ts +++ b/packages/core/src/markdown/html-to-mdast.ts @@ -1,4 +1,3 @@ - import type { Root as HastRoot } from 'hast'; import type { List, Root as MdastRoot } from 'mdast'; import rehypeParse from 'rehype-parse'; @@ -86,8 +85,7 @@ export function htmlToMdast(html: string, options?: HtmlToMdastOptions): MdastRo throw new HtmlPayloadTooLargeError(html.length, maxBytes); } - const processor = unified() - .use(rehypeParse, { fragment: true }); + const processor = unified().use(rehypeParse, { fragment: true }); for (const plugin of cleanupPlugins) { processor.use(plugin); diff --git a/packages/core/src/markdown/image-promoter.ts b/packages/core/src/markdown/image-promoter.ts index 994eb6917..706fa9afb 100644 --- a/packages/core/src/markdown/image-promoter.ts +++ b/packages/core/src/markdown/image-promoter.ts @@ -1,4 +1,3 @@ - import type { Image, Paragraph, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/index.ts b/packages/core/src/markdown/index.ts index fc30bda02..8b36807fd 100644 --- a/packages/core/src/markdown/index.ts +++ b/packages/core/src/markdown/index.ts @@ -1,4 +1,3 @@ - import { type FromProseMirrorOptions, fromPmMark, @@ -197,7 +196,6 @@ export class MarkdownManager { } } - const registry = createRegistry(); function destructureAttrs( @@ -246,7 +244,6 @@ function destructureAttrs( return result; } - function hasDirtyDescendant(node: PmNode): boolean { let found = false; node.descendants((child) => { @@ -266,7 +263,6 @@ function effectiveDirty(node: PmNode, freshnessChecker?: StructuralFreshnessChec return freshnessChecker ? freshnessChecker.isDiverged(node.toJSON()) : false; } - function isEmptyMdastParagraph(node: MdastNodes): boolean { if (node.type !== 'paragraph') return false; const children = node.children ?? []; @@ -335,7 +331,6 @@ function extractTextFromMdastNodes(nodes: MdastNodes[]): string { return out; } - import { AUDIO_EXTENSIONS, FILE_ATTACHMENT_EXTENSIONS, @@ -582,7 +577,6 @@ function buildMdastToPmHandlers( })); } - if (m.emphasis) { handlers.emphasis = toPmMark(m.emphasis, (node: Emphasis) => ({ sourceDelimiter: node.data?.sourceDelimiter ?? '*', @@ -663,7 +657,6 @@ function buildMdastToPmHandlers( })); } - if (m.link) { const sourceLiteralMark = m.sourceLiteral; handlers.link = (node: Link, _parent: MdastParent, state: MdastToPmState) => { @@ -942,7 +935,6 @@ function buildMdastToPmHandlers( }; } - const blockUnknownHandler = (node: { type: string; position?: { start: { offset: number }; end: { offset: number } }; @@ -1068,7 +1060,6 @@ function buildMdastToPmHandlers( return handlers as RemarkProseMirrorOptions['handlers']; } - function buildPmToMdastHandlers( schema: Schema, freshness: FreshnessCheckerHolder, diff --git a/packages/core/src/markdown/lint/config-files.ts b/packages/core/src/markdown/lint/config-files.ts index 94b34dee1..312c9c74b 100644 --- a/packages/core/src/markdown/lint/config-files.ts +++ b/packages/core/src/markdown/lint/config-files.ts @@ -1,4 +1,3 @@ - const MARKDOWNLINT_JSON_CONFIG_FILES: ReadonlySet = new Set([ '.markdownlint.json', '.markdownlint.jsonc', diff --git a/packages/core/src/markdown/lint/config-schemas.ts b/packages/core/src/markdown/lint/config-schemas.ts index 207cd95c2..e78a1381d 100644 --- a/packages/core/src/markdown/lint/config-schemas.ts +++ b/packages/core/src/markdown/lint/config-schemas.ts @@ -29,7 +29,6 @@ const fullPluginShape = Object.fromEntries( LINT_PLUGINS.map((plugin) => [plugin.id, plugin.sliceSchema]), ) as z.ZodRawShape; - export const LinterConfigSchema = z.object({ enabled: z.boolean(), plugins: z.object(fullPluginShape), diff --git a/packages/core/src/markdown/lint/default-config.ts b/packages/core/src/markdown/lint/default-config.ts index a51f2ffb0..d48eea5e4 100644 --- a/packages/core/src/markdown/lint/default-config.ts +++ b/packages/core/src/markdown/lint/default-config.ts @@ -6,7 +6,6 @@ export const DEFAULT_MARKDOWNLINT_CONFIG: Record | undefined, ): Configuration { diff --git a/packages/core/src/markdown/lint/index.ts b/packages/core/src/markdown/lint/index.ts index d763d8d28..8a154ce30 100644 --- a/packages/core/src/markdown/lint/index.ts +++ b/packages/core/src/markdown/lint/index.ts @@ -1,4 +1,3 @@ - import { LINT_PLUGINS, type LinterConfig } from './plugins.ts'; import type { LintDiagnostic } from './types.ts'; diff --git a/packages/core/src/markdown/lint/plugins.ts b/packages/core/src/markdown/lint/plugins.ts index 65a1b564c..1cd40ef3a 100644 --- a/packages/core/src/markdown/lint/plugins.ts +++ b/packages/core/src/markdown/lint/plugins.ts @@ -1,4 +1,3 @@ - import { z } from 'zod'; import { DEFAULT_MARKDOWNLINT_CONFIG, resolveMarkdownlintConfig } from './default-config.ts'; import { fixMarkdownText, runMarkdownlint } from './markdownlint-runner.ts'; @@ -9,7 +8,6 @@ import { type MarkdownlintSlice, } from './types.ts'; - const MarkdownlintRuleSettingSchema = z.union([ z.boolean(), z.enum(MARKDOWNLINT_RULE_SEVERITIES), diff --git a/packages/core/src/markdown/lint/types.ts b/packages/core/src/markdown/lint/types.ts index 3651bd7eb..617f6e5b9 100644 --- a/packages/core/src/markdown/lint/types.ts +++ b/packages/core/src/markdown/lint/types.ts @@ -1,4 +1,3 @@ - export const LINT_PLUGIN_IDS = ['markdownlint'] as const; export type LintPluginId = (typeof LINT_PLUGIN_IDS)[number]; @@ -42,7 +41,6 @@ export interface MarkdownlintSlice { rules: Record; } - interface RuleOptionSpecBase { key: string; description: string; diff --git a/packages/core/src/markdown/math-promoter.ts b/packages/core/src/markdown/math-promoter.ts index bd79424c7..a5b511f25 100644 --- a/packages/core/src/markdown/math-promoter.ts +++ b/packages/core/src/markdown/math-promoter.ts @@ -1,4 +1,3 @@ - import type { Code, Root } from 'mdast'; import type { Math as MdastMath } from 'mdast-util-math'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; diff --git a/packages/core/src/markdown/mdast-to-hast-handlers.ts b/packages/core/src/markdown/mdast-to-hast-handlers.ts index f84967798..679c72ef7 100644 --- a/packages/core/src/markdown/mdast-to-hast-handlers.ts +++ b/packages/core/src/markdown/mdast-to-hast-handlers.ts @@ -1,4 +1,3 @@ - import type { Comment, Element, ElementContent, Properties } from 'hast'; import type { FootnoteDefinition, FootnoteReference } from 'mdast'; import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx'; diff --git a/packages/core/src/markdown/mdast-to-html.ts b/packages/core/src/markdown/mdast-to-html.ts index df788a80d..8ce2b2492 100644 --- a/packages/core/src/markdown/mdast-to-html.ts +++ b/packages/core/src/markdown/mdast-to-html.ts @@ -1,4 +1,3 @@ - import type { Element, Root as HastRoot, Text as HastText } from 'hast'; import type { Root as MdastRoot, Text as MdastText } from 'mdast'; import type { Handler } from 'mdast-util-to-hast'; diff --git a/packages/core/src/markdown/merged-walker.ts b/packages/core/src/markdown/merged-walker.ts index fa3cfcd86..bfd7e3109 100644 --- a/packages/core/src/markdown/merged-walker.ts +++ b/packages/core/src/markdown/merged-walker.ts @@ -1,4 +1,3 @@ - import type { Nodes, Parent, Root } from 'mdast'; import { SKIP, visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/mermaid-promoter.ts b/packages/core/src/markdown/mermaid-promoter.ts index 17ed2b829..1a897fca8 100644 --- a/packages/core/src/markdown/mermaid-promoter.ts +++ b/packages/core/src/markdown/mermaid-promoter.ts @@ -1,4 +1,3 @@ - import type { Code, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/parse-with-fallback.ts b/packages/core/src/markdown/parse-with-fallback.ts index 7e4a9a48b..9864b3b60 100644 --- a/packages/core/src/markdown/parse-with-fallback.ts +++ b/packages/core/src/markdown/parse-with-fallback.ts @@ -168,7 +168,6 @@ export function parseRecursive( } } - interface VFilePlace { offset?: number; start?: { offset?: number }; @@ -187,7 +186,6 @@ function extractErrorOffset(err: unknown): number | undefined { return undefined; } - interface Region { start: number; end: number; @@ -211,7 +209,6 @@ function nearestBlankLineAfter(src: string, offset: number): number | null { return null; } - export interface TagEvent { kind: 'open' | 'close' | 'self-close'; name: string; @@ -358,7 +355,6 @@ function findFallbackRegion(src: string, errorOffset: number): Region { return { start: blockStart, end: blockEnd }; } - interface SourceBlock { src: string; start: number; @@ -456,7 +452,6 @@ function tryPerBlockFallback( }; } - function wholeDocRawText(source: string): JSONContent { return { type: 'doc', diff --git a/packages/core/src/markdown/parser-drop-closure.ts b/packages/core/src/markdown/parser-drop-closure.ts index 43d420187..e6ab4fd57 100644 --- a/packages/core/src/markdown/parser-drop-closure.ts +++ b/packages/core/src/markdown/parser-drop-closure.ts @@ -1,4 +1,3 @@ - export type DroppedTokenAdjudication = | { kind: 'format-dof-axis'; diff --git a/packages/core/src/markdown/pipeline.ts b/packages/core/src/markdown/pipeline.ts index e9e507469..7f2d5adca 100644 --- a/packages/core/src/markdown/pipeline.ts +++ b/packages/core/src/markdown/pipeline.ts @@ -1,4 +1,3 @@ - import { type FromProseMirrorOptions, fromProseMirror, diff --git a/packages/core/src/markdown/position-aware-join.ts b/packages/core/src/markdown/position-aware-join.ts index f8f5e8530..5a5e4c42a 100644 --- a/packages/core/src/markdown/position-aware-join.ts +++ b/packages/core/src/markdown/position-aware-join.ts @@ -1,4 +1,3 @@ - import type { Join } from 'mdast-util-to-markdown'; import type { Position } from 'unist'; diff --git a/packages/core/src/markdown/position-slice.ts b/packages/core/src/markdown/position-slice.ts index e5b4c9eba..0f399d34c 100644 --- a/packages/core/src/markdown/position-slice.ts +++ b/packages/core/src/markdown/position-slice.ts @@ -1,4 +1,3 @@ - import type { Nodes, Root } from 'mdast'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; @@ -477,8 +476,7 @@ export function applyPositionSliceToNode( const first = source[startOff]; if (first !== '[' && first !== '<' && !node.title) { node.data.sourceStyle = 'gfm-autolink'; - } - else if (first === '[') { + } else if (first === '[') { const slice = source.slice(startOff, endOff); const closeBracketIdx = slice.lastIndexOf(']('); if (closeBracketIdx !== -1) { diff --git a/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts b/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts index ca225ce9b..db10fa54b 100644 --- a/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts +++ b/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts @@ -1,4 +1,3 @@ - import type { Comment, Element, Root, Text } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts b/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts index 3a2524a9a..34068d103 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts @@ -1,4 +1,3 @@ - import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts b/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts index 2279a0189..93151d43b 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts @@ -1,4 +1,3 @@ - import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts b/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts index a0f467c42..188f02d83 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts @@ -1,4 +1,3 @@ - import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts b/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts index 4e8542a21..4ba2c3418 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts @@ -1,4 +1,3 @@ - import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts b/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts index a3c96b24a..1a74b1272 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts @@ -1,4 +1,3 @@ - import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts b/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts index e95cf43d6..de35e7b79 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts @@ -1,4 +1,3 @@ - import type { Comment, Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts b/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts index ffdb4d5b5..b4be1b2ea 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts @@ -1,4 +1,3 @@ - import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts b/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts index e2a96d1a7..3c8d88b7c 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts @@ -1,4 +1,3 @@ - import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/resolve-image-url.ts b/packages/core/src/markdown/resolve-image-url.ts index 0389575ac..cf2c9da8e 100644 --- a/packages/core/src/markdown/resolve-image-url.ts +++ b/packages/core/src/markdown/resolve-image-url.ts @@ -1,4 +1,3 @@ - import { isRelativeUrl } from './safe-url.ts'; function isDevDiagnosticContext(): boolean { diff --git a/packages/core/src/markdown/safe-url.ts b/packages/core/src/markdown/safe-url.ts index 8be632ba4..4d3250d3a 100644 --- a/packages/core/src/markdown/safe-url.ts +++ b/packages/core/src/markdown/safe-url.ts @@ -1,4 +1,3 @@ - export const SAFE_URL_SCHEMES = ['https', 'http', 'mailto', 'tel', 'ftp', 'sms'] as const; const SCHEME_ALT = SAFE_URL_SCHEMES.map((s) => `${s}:`).join('|'); diff --git a/packages/core/src/markdown/serialize-helpers.ts b/packages/core/src/markdown/serialize-helpers.ts index 542204403..46cc6a074 100644 --- a/packages/core/src/markdown/serialize-helpers.ts +++ b/packages/core/src/markdown/serialize-helpers.ts @@ -1,4 +1,3 @@ - import type { Node as PmNode } from '@tiptap/pm/model'; import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import type { PropDef, SerializeContext } from '../registry/types.ts'; diff --git a/packages/core/src/markdown/single-dollar-math-promoter.ts b/packages/core/src/markdown/single-dollar-math-promoter.ts index fe73659e6..be97d4d62 100644 --- a/packages/core/src/markdown/single-dollar-math-promoter.ts +++ b/packages/core/src/markdown/single-dollar-math-promoter.ts @@ -1,4 +1,3 @@ - import type { PhrasingContent, Root, Text } from 'mdast'; import type { InlineMath } from 'mdast-util-math'; import { SKIP, visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/to-markdown-handlers.ts b/packages/core/src/markdown/to-markdown-handlers.ts index 7bce04302..8f4e9a41c 100644 --- a/packages/core/src/markdown/to-markdown-handlers.ts +++ b/packages/core/src/markdown/to-markdown-handlers.ts @@ -1,4 +1,3 @@ - import type { Nodes, Parents } from 'mdast'; import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import type { Handle, Info, State } from 'mdast-util-to-markdown'; diff --git a/packages/core/src/markdown/unknown-mdast-guard.ts b/packages/core/src/markdown/unknown-mdast-guard.ts index c5bfd2386..cd245c38c 100644 --- a/packages/core/src/markdown/unknown-mdast-guard.ts +++ b/packages/core/src/markdown/unknown-mdast-guard.ts @@ -1,4 +1,3 @@ - import type { Root as MdastRoot } from 'mdast'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/whitespace-char-ref.ts b/packages/core/src/markdown/whitespace-char-ref.ts index ac9f5239b..d578476a7 100644 --- a/packages/core/src/markdown/whitespace-char-ref.ts +++ b/packages/core/src/markdown/whitespace-char-ref.ts @@ -1,4 +1,3 @@ - const INLINE_WHITESPACE_BY_CODE: ReadonlyMap = new Map([ [0x20, ' '], [0x09, '\t'], diff --git a/packages/core/src/markdown/wiki-link-micromark.ts b/packages/core/src/markdown/wiki-link-micromark.ts index afd9936fe..6dad26200 100644 --- a/packages/core/src/markdown/wiki-link-micromark.ts +++ b/packages/core/src/markdown/wiki-link-micromark.ts @@ -17,7 +17,6 @@ declare module 'micromark-util-types' { } } - const CODE_BANG = 33; // ! const CODE_LBRACKET = 91; // [ const CODE_RBRACKET = 93; // ] @@ -261,7 +260,6 @@ export function wikiLinkSyntax(): Extension { }; } - function enterWikiLink(this: CompileContext, token: Token) { this.enter( { @@ -387,7 +385,6 @@ export const wikiLinkToMarkdown: { unsafe: [{ character: '[', inConstruct: ['phrasing'] }], }; - const MICROMARK_EXT = wikiLinkSyntax(); export function remarkWikiLink(this: Processor) { diff --git a/packages/core/src/schemas/api/sync-seed.test.ts b/packages/core/src/schemas/api/sync-seed.test.ts index 234b97916..bf1544f50 100644 --- a/packages/core/src/schemas/api/sync-seed.test.ts +++ b/packages/core/src/schemas/api/sync-seed.test.ts @@ -3,6 +3,7 @@ import { ConflictEntrySchema, InstallSkillSuccessSchema, ProblemTypeSchema, + PushPermissionSchema, SeedApplyRequestSchema, SeedApplySuccessSchema, SeedPlanSuccessSchema, @@ -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( diff --git a/packages/core/src/schemas/api/sync-seed.ts b/packages/core/src/schemas/api/sync-seed.ts index 7cbfd3ccc..4483d2dfd 100644 --- a/packages/core/src/schemas/api/sync-seed.ts +++ b/packages/core/src/schemas/api/sync-seed.ts @@ -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(), diff --git a/packages/server/src/github-permissions.test.ts b/packages/server/src/github-permissions.test.ts index 4dba0c0d8..b3dd05d91 100644 --- a/packages/server/src/github-permissions.test.ts +++ b/packages/server/src/github-permissions.test.ts @@ -302,6 +302,84 @@ describe('checkPushPermission — token resolution', () => { expect(calls).toHaveLength(0); }); + test('anonymous + transport ssh → unknown/ssh-unverified with NO HTTP call', async () => { + // Self-hosted forge (Gitea/Forgejo) pushed over SSH: no gh/OK token can + // ever exist for that host, but the push auths with SSH keys. The probe + // must abstain, not deny — denying pauses sync for a fully working setup. + const { fetch, calls } = mockFetch(() => jsonResponse(200, {})); + const { store } = fakeStore(null); + const result = await checkPushPermission({ + owner: 'acme', + repo: 'kb', + host: 'git.example.com', + transport: 'ssh', + detectGh: ghUnavailable(), + tokenStore: store, + _fetchFn: fetch, + }); + expect(result).toEqual({ kind: 'unknown', error: 'ssh-unverified' }); + expect(calls).toHaveLength(0); + }); + + test('anonymous + transport git → unknown/ssh-unverified (unauthenticated protocol, tokens irrelevant)', async () => { + const { fetch, calls } = mockFetch(() => jsonResponse(200, {})); + const result = await checkPushPermission({ + owner: 'acme', + repo: 'kb', + host: 'git.example.com', + transport: 'git', + _fetchFn: fetch, + }); + expect(result).toEqual({ kind: 'unknown', error: 'ssh-unverified' }); + expect(calls).toHaveLength(0); + }); + + test('anonymous + explicit transport https → denied/not-authenticated unchanged', async () => { + // HTTPS pushes auth with tokens; no token ⇒ the push cannot succeed, so + // the signed-out denial (and its Sign-in affordance) stays correct — + // including for GHES over HTTPS. + const { fetch, calls } = mockFetch(() => jsonResponse(200, {})); + const result = await checkPushPermission({ + owner: 'acme', + repo: 'kb', + host: 'ghes.acme.test', + transport: 'https', + _fetchFn: fetch, + }); + expect(result).toEqual({ kind: 'denied', reason: 'not-authenticated' }); + expect(calls).toHaveLength(0); + }); + + test('anonymous + github.com over SSH is lenient too (deliberate)', async () => { + // A github.com user with SSH keys and no gh CLI / stored token pushes + // fine; keying leniency on transport (not host) un-breaks them as well. + const { fetch, calls } = mockFetch(() => jsonResponse(200, {})); + const result = await checkPushPermission({ + owner: 'inkeep', + repo: 'open-knowledge', + host: 'github.com', + transport: 'ssh', + _fetchFn: fetch, + }); + expect(result).toEqual({ kind: 'unknown', error: 'ssh-unverified' }); + expect(calls).toHaveLength(0); + }); + + test('transport ssh with a resolved credential still probes normally', async () => { + // Transport only gates the ANONYMOUS branch. With a token the API answer + // is authoritative regardless of how git would transport the push. + const { fetch, calls } = mockFetch(() => jsonResponse(200, { permissions: { push: true } })); + const result = await checkPushPermission({ + owner: 'inkeep', + repo: 'open-knowledge', + transport: 'ssh', + detectGh: ghAvailable(), + _fetchFn: fetch, + }); + expect(result).toEqual({ kind: 'allowed' }); + expect(calls).toHaveLength(1); + }); + test('gh detection is scoped to the requested host', async () => { const seenHosts: Array = []; const detectGh: DetectGhFn = (host) => { diff --git a/packages/server/src/github-permissions.ts b/packages/server/src/github-permissions.ts index 6d1c80fbf..fd9fc1909 100644 --- a/packages/server/src/github-permissions.ts +++ b/packages/server/src/github-permissions.ts @@ -18,6 +18,7 @@ import type { Counter, Histogram } from '@opentelemetry/api'; import { getLogger } from './logger.ts'; +import type { OriginTransport } from './share/git-context.ts'; import { getMeter } from './telemetry.ts'; const log = getLogger('github-permissions'); @@ -36,12 +37,18 @@ type PushPermissionDeniedReason = | 'repo-not-found' | 'not-authenticated'; +// 'ssh-unverified' = anonymous credential resolution over a non-token +// transport (ssh/git origins). git auths those pushes with SSH keys, so token +// absence proves nothing about push ability — the probe abstains and lets the +// real push decide. Distinct from the transient errors so telemetry can tell +// "we couldn't reach GitHub" apart from "we deliberately didn't ask". type PushPermissionUnknownError = | 'network' | 'timeout' | 'rate-limit' | 'token-invalid' - | 'malformed-response'; + | 'malformed-response' + | 'ssh-unverified'; /** * Outcome of a single push-permission probe. @@ -72,6 +79,14 @@ export interface CheckPushPermissionOptions { repo: string; /** GitHub host. Defaults to `'github.com'`. */ host?: string; + /** + * How the origin URL authenticates a push. Defaults to `'https'` (token + * auth — the pre-transport behavior). For `'ssh'`/`'git'` origins an + * anonymous probe abstains (`unknown/ssh-unverified`) instead of denying: + * no token ⇒ no HTTPS push, but SSH pushes auth with keys the probe + * cannot see. + */ + transport?: OriginTransport; /** Tier A resolver (`gh` CLI). Defaults to "no gh available". */ detectGh?: DetectGhFn; /** Tier B/C credential store. Omit to skip stored-token resolution. */ @@ -207,6 +222,7 @@ async function runProbe(opts: CheckPushPermissionOptions): Promise ({ available: false }), tokenStore, _fetchFn = fetch, @@ -219,13 +235,30 @@ async function runProbe(opts: CheckPushPermissionOptions): Promise { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'https', }); }); @@ -131,6 +132,7 @@ describe('readOriginGitHubRepo', () => { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'ssh', }); }); @@ -143,6 +145,7 @@ describe('readOriginGitHubRepo', () => { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'ssh', }); }); @@ -155,6 +158,7 @@ describe('readOriginGitHubRepo', () => { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'https', }); }); @@ -178,6 +182,7 @@ describe('readOriginGitHubRepo', () => { host: 'ghes.acme.test', owner: 'inkeep', repo: 'open-knowledge', + transport: 'https', }); }); @@ -190,6 +195,7 @@ describe('readOriginGitHubRepo', () => { host: 'github.corp.example.com', owner: 'team', repo: 'kb', + transport: 'ssh', }); }); @@ -204,6 +210,7 @@ describe('readOriginGitHubRepo', () => { host: 'ghes.acme.test', owner: 'acme', repo: 'kb', + transport: 'https', }); }); @@ -216,6 +223,7 @@ describe('readOriginGitHubRepo', () => { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'git', }); }); @@ -228,6 +236,23 @@ describe('readOriginGitHubRepo', () => { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'https', + }); + }); + + test('ssh:// origin with a port carries transport ssh and a port-stripped host', () => { + // The exact shape of the local-forge repro: `ssh://git@localhost:2222/...` + // against a self-hosted Gitea. Must classify as ssh so the anonymous + // probe abstains instead of pausing sync. + seedRepo(dir, { + config: '[remote "origin"]\n\turl = ssh://git@git.acme.test:2222/acme/kb.git\n', + }); + expect(readOriginGitHubRepo(dir)).toEqual({ + kind: 'ok', + host: 'git.acme.test', + owner: 'acme', + repo: 'kb', + transport: 'ssh', }); }); @@ -276,6 +301,7 @@ describe('readOriginGitHubRepo', () => { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'https', }); }); @@ -289,6 +315,7 @@ describe('readOriginGitHubRepo', () => { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'https', }); }); }); @@ -502,6 +529,7 @@ describe('linked-worktree common-dir resolution', () => { host: 'github.com', owner: 'inkeep', repo: 'open-knowledge', + transport: 'https', }); }); diff --git a/packages/server/src/share/git-context.ts b/packages/server/src/share/git-context.ts index 7a14910fd..612a267ee 100644 --- a/packages/server/src/share/git-context.ts +++ b/packages/server/src/share/git-context.ts @@ -33,9 +33,18 @@ import { getLogger } from '../logger.ts'; const log = getLogger('git-context'); +/** + * Which URL form the origin was written in. Determines how a push would + * authenticate: `https` pushes auth with tokens; `ssh` (both `ssh://` and + * scp-style) auths with SSH keys; `git://` is unauthenticated. The + * push-permission probe keys leniency off this — token absence proves nothing + * about push ability for a non-token transport. + */ +export type OriginTransport = 'https' | 'ssh' | 'git'; + /** Outcome of `readOriginGitHubRepo`. */ export type OriginResult = - | { kind: 'ok'; host: string; owner: string; repo: string } + | { kind: 'ok'; host: string; owner: string; repo: string; transport: OriginTransport } | { kind: 'no-remote' } | { kind: 'non-github' }; @@ -58,6 +67,7 @@ interface ParsedOriginRepo { host: string; owner: string; repo: string; + transport: OriginTransport; } /** @@ -82,30 +92,35 @@ function parseGitHubOriginUrl(originUrl: string): ParsedOriginRepo | null { const raw = originUrl.trim(); if (!raw) return null; - const classify = (host: string, owner: string, repo: string): ParsedOriginRepo | null => { + const classify = ( + host: string, + owner: string, + repo: string, + transport: OriginTransport, + ): ParsedOriginRepo | null => { const normalized = normalizeGitHost(host); if (KNOWN_NON_GITHUB_GIT_HOSTS.has(normalized)) return null; - return { host: normalized, owner, repo }; + return { host: normalized, owner, repo, transport }; }; // https://[:port]//(.git)? let m = /^https?:\/\/([\w.-]+(?::\d+)?)\/([\w.\-~%]+)\/([\w.\-~%]+?)(?:\.git)?\/?$/.exec(raw); - if (m) return classify(m[1], m[2], m[3]); + if (m) return classify(m[1], m[2], m[3], 'https'); // ssh://[user@][:port]//(.git)? m = /^ssh:\/\/(?:[\w.-]+@)?([\w.-]+)(?::\d+)?\/([\w.\-~%]+)\/([\w.\-~%]+?)(?:\.git)?\/?$/.exec( raw, ); - if (m) return classify(m[1], m[2], m[3]); + if (m) return classify(m[1], m[2], m[3], 'ssh'); // @:/(.git)? (scp-style; `@` is required, so // Windows drive paths like `C:\x` can never match) m = /^[\w.-]+@([\w.-]+):([\w.\-~%]+)\/([\w.\-~%]+?)(?:\.git)?$/.exec(raw); - if (m) return classify(m[1], m[2], m[3]); + if (m) return classify(m[1], m[2], m[3], 'ssh'); // git://[:port]//(.git)? m = /^git:\/\/([\w.-]+(?::\d+)?)\/([\w.\-~%]+)\/([\w.\-~%]+?)(?:\.git)?\/?$/.exec(raw); - if (m) return classify(m[1], m[2], m[3]); + if (m) return classify(m[1], m[2], m[3], 'git'); return null; } @@ -131,8 +146,8 @@ export function readOriginGitHubRepo(projectDir: string): OriginResult { const parsed = readParsedOrigin(projectDir); if (!parsed) return { kind: 'no-remote' }; if (parsed.github) { - const { host, owner, repo } = parsed.github; - return { kind: 'ok', host, owner, repo }; + const { host, owner, repo, transport } = parsed.github; + return { kind: 'ok', host, owner, repo, transport }; } // Origin URL present but a known non-GitHub forge or unparseable — surface // as `non-github` so the caller renders the matching toast. diff --git a/packages/server/src/sync-engine.test.ts b/packages/server/src/sync-engine.test.ts index f4ad0d3b8..722a268c4 100644 --- a/packages/server/src/sync-engine.test.ts +++ b/packages/server/src/sync-engine.test.ts @@ -1597,6 +1597,39 @@ describe('SyncEngine push-permission probe', () => { expect(persisted).toBe(false); }); + test('passes the origin transport through to the probe (ssh origin)', async () => { + await initGitWithOrigin('git@git.example.com:acme/kb.git'); + const probe = fakeProbe({ kind: 'unknown', error: 'ssh-unverified' }); + const engine = makeProbeEngine({ syncEnabled: false, fakeProbe: probe.fn }); + await engine.start(); + await waitForPushPermissionResolved(engine); + expect(probe.opts[0]).toMatchObject({ + owner: 'acme', + repo: 'kb', + host: 'git.example.com', + transport: 'ssh', + }); + }); + + test('ssh-unverified probe result does NOT pause the engine (self-hosted forge over SSH)', async () => { + // The regression behind the forge sync pause: an SSH-origin user with no + // gh/OK token used to land in denied/not-authenticated → pausedReason= + // 'no-push-permission' → disabled, with a GitHub Sign-in button that can + // never help. The abstaining probe result must leave sync running. + await initGitWithOrigin('git@git.example.com:acme/kb.git'); + const probe = fakeProbe({ kind: 'unknown', error: 'ssh-unverified' }); + const engine = makeProbeEngine({ syncEnabled: true, fakeProbe: probe.fn }); + await engine.start(); + await waitForPushPermissionResolved(engine); + const status = engine.getStatus(); + expect(status.pushPermission).toEqual({ + checkStatus: 'unknown', + unknownError: 'ssh-unverified', + }); + expect(status.state).toBe('idle'); + expect(status.pausedReason).not.toBe('no-push-permission'); + }); + test('records `unknown` without changing state', async () => { await initGitWithOrigin(); const probe = fakeProbe({ kind: 'unknown', error: 'network' }); diff --git a/packages/server/src/sync-engine.ts b/packages/server/src/sync-engine.ts index 64a1fa81e..560f1d916 100644 --- a/packages/server/src/sync-engine.ts +++ b/packages/server/src/sync-engine.ts @@ -92,15 +92,16 @@ export type PushPermissionStatus = | { checkStatus: 'allowed' } | { checkStatus: 'denied'; - deniedReason: - | 'no-collaborator' - | 'private-no-access' - | 'repo-not-found' - | 'not-authenticated'; + // Both payload unions derive structurally from the source-of-truth + // `PushPermission` in github-permissions.ts so a code added there can't + // silently drift from this wire shape. The Zod enum in core's + // sync-seed.ts still needs a manual matching update; its round-trip + // test is the runtime net for that half. + deniedReason: Extract['reason']; } | { checkStatus: 'unknown'; - unknownError?: 'network' | 'timeout' | 'rate-limit' | 'token-invalid' | 'malformed-response'; + unknownError?: Extract['error']; }; /** Flatten the tagged `PushPermission` from `github-permissions.ts` to wire shape. */ @@ -875,6 +876,7 @@ export class SyncEngine { owner: origin.owner, repo: origin.repo, host: origin.host, + transport: origin.transport, detectGh: this.detectGh, tokenStore: this.tokenStore, });