diff --git a/README.md b/README.md index d14d840..13d2f18 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ command. **From inside your agent** — install the hub's own plugin and let the agent do it: ```sh -dsh plugin --profile web add github:stvlynn/dsh.fish#main +dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub ``` It registers four tools: `hub_search`, `hub_show`, `hub_install` and diff --git a/backend/src/domain/artifact/install-plan.test.ts b/backend/src/domain/artifact/install-plan.test.ts index 084c583..6dbfed2 100644 --- a/backend/src/domain/artifact/install-plan.test.ts +++ b/backend/src/domain/artifact/install-plan.test.ts @@ -78,6 +78,25 @@ describe('buildInstallPlan', () => { expect(unpinned.warningKeys).toContain('install.warning.unpinnedGitSpec') }) + it('selects a subdirectory bundle with pnpm\'s git path selector', () => { + const plan = buildInstallPlan( + artifact( + 'bundle', + { kind: 'bundle', requiresBuild: true }, + githubSource({ + owner: 'stvlynn', + repo: 'dsh.fish', + path: 'packages/dsh-plugin-hub', + commit: 'c'.repeat(40), + }), + ), + target, + ) + expect(plan.manualCommands[0]).toBe( + `dsh plugin --profile web add github:stvlynn/dsh.fish#${'c'.repeat(40)}&path:packages/dsh-plugin-hub`, + ) + }) + it('adds every profile bundle in declared order', () => { const plan = buildInstallPlan( artifact('profile', { diff --git a/backend/src/domain/artifact/source-ref.test.ts b/backend/src/domain/artifact/source-ref.test.ts new file mode 100644 index 0000000..eb50164 --- /dev/null +++ b/backend/src/domain/artifact/source-ref.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { githubSource, npmSource, packageSpec } from './source-ref.js' + +describe('packageSpec', () => { + it('pins an npm package to its latest version', () => { + expect(packageSpec(npmSource('dsh-example', '1.2.3'))).toBe('dsh-example@1.2.3') + }) + + it('emits a git host spec for a repository root', () => { + expect(packageSpec(githubSource({ owner: 'acme', repo: 'thing' }))).toBe( + 'github:acme/thing', + ) + }) + + it('pins a commit when the registry knows one', () => { + const commit = 'a'.repeat(40) + expect(packageSpec(githubSource({ owner: 'acme', repo: 'thing', commit }))).toBe( + `github:acme/thing#${commit}`, + ) + }) + + it('selects a subdirectory package the way pnpm git installs require', () => { + expect( + packageSpec( + githubSource({ owner: 'stvlynn', repo: 'dsh.fish', path: 'packages/dsh-plugin-hub' }), + ), + ).toBe('github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub') + }) + + it('combines a pinned commit with a subdirectory selector', () => { + const commit = 'b'.repeat(40) + expect( + packageSpec( + githubSource({ + owner: 'stvlynn', + repo: 'dsh.fish', + path: 'packages/dsh-plugin-hub', + commit, + }), + ), + ).toBe(`github:stvlynn/dsh.fish#${commit}&path:packages/dsh-plugin-hub`) + }) +}) diff --git a/backend/src/domain/artifact/source-ref.ts b/backend/src/domain/artifact/source-ref.ts index ff0ae0b..a306084 100644 --- a/backend/src/domain/artifact/source-ref.ts +++ b/backend/src/domain/artifact/source-ref.ts @@ -104,10 +104,16 @@ export function packageSpec(source: SourceRef): string | undefined { switch (source.origin) { case 'npm': return `${source.packageName}@${source.latestVersion}` - case 'github': - return source.commit === undefined - ? `github:${source.owner}/${source.repo}` - : `github:${source.owner}/${source.repo}#${source.commit}` + case 'github': { + const name = `github:${source.owner}/${source.repo}` + const selectors: string[] = [] + if (source.commit !== undefined) selectors.push(source.commit) + // pnpm's git protocol: `#&path:` selects a workspace + // package inside the clone. Omitting `path` installs the repository + // root, which for a monorepo is usually not the bundle. + if (source.path !== undefined) selectors.push(`path:${source.path}`) + return selectors.length === 0 ? name : `${name}#${selectors.join('&')}` + } case 'submission': return undefined } diff --git a/docs/decisions/adr-0001-plugin-hub-architecture.md b/docs/decisions/adr-0001-plugin-hub-architecture.md index 2d5facf..0ba39fa 100644 --- a/docs/decisions/adr-0001-plugin-hub-architecture.md +++ b/docs/decisions/adr-0001-plugin-hub-architecture.md @@ -127,9 +127,13 @@ never reads, which is how a registry ends up with an empty long tail. variant, one `buildInstallPlan` branch, one installer branch, and message keys. Nothing else changes. - The `dsh-hub` plugin binds to `@deepseek-ai/dsh-tools` as a peer dependency and - declares its types locally, because that package is not yet installable - standalone from npm during the harness's developer preview. When it publishes - completely, `packages/dsh-plugin-hub/src/harness.d.ts` should be deleted and - the real packages added as devDependencies. +declares its types locally, because that package is not yet installable +standalone from npm during the harness's developer preview. When it publishes +completely, `packages/dsh-plugin-hub/src/harness.d.ts` should be deleted and +the real packages added as devDependencies. The plugin's `Config` export is a +Standard Schema (`~standard`), not a defaults object: Cordis rejects a plain +object and the plugin would not start. Git installs must name the subdirectory +package (`github:owner/repo#path:packages/dsh-plugin-hub`); the repository root +is the website, not the bundle. - D1 has no cross-statement transactions, so multi-table writes use `db.batch`, which D1 applies atomically. diff --git a/docs/project/architecture.md b/docs/project/architecture.md index 73d2759..7b36083 100644 --- a/docs/project/architecture.md +++ b/docs/project/architecture.md @@ -121,6 +121,13 @@ yields nothing — the harness would load nothing from it either. | `SKILL.md` with `name` + `description` frontmatter | `skill` | | `agent.cordis.yml` | `agent-preset` | +Those probes run against the repository root (or an explicit subdirectory on +submit). A monorepo whose bundle lives under `packages/` — this project's own +`dsh-hub` plugin included — is therefore submitted as +`github://`, and `packageSpec` emits pnpm's +`#path:` selector so `dsh plugin add` installs that package rather than +the root. + Those probes run before anything else is fetched, so a repository that is not a plugin costs three reads and no API quota — that ordering is what makes it affordable to page deep into a topic of several thousand repositories. diff --git a/frontend/src/pages/device/device-page.tsx b/frontend/src/pages/device/device-page.tsx index e2d1f19..3cc393a 100644 --- a/frontend/src/pages/device/device-page.tsx +++ b/frontend/src/pages/device/device-page.tsx @@ -39,13 +39,34 @@ export default function DevicePage() { const [status, setStatus] = useState('idle') const [busy, setBusy] = useState(false) - // A prefilled complete-URI visit should land on the confirmation step rather - // than making the user re-type a code the link already carried. + // Better Auth binds the pending code to this session on GET /device. Approve + // and deny refuse an unclaimed code, so a complete code — typed or prefilled + // — has to be claimed before the confirmation step is shown. useEffect(() => { - if (code.length === CODE_LENGTH && phase === 'entering') setPhase('confirming') - // Runs once for the prefilled case; later transitions are driven by input. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + if (!session?.user || code.length !== CODE_LENGTH || phase !== 'entering') return + let cancelled = false + setBusy(true) + void authClient + .device({ query: { user_code: code } }) + .then((result) => { + if (cancelled) return + if (result.error) { + setStatus('error') + return + } + setStatus('idle') + setPhase('confirming') + }) + .catch(() => { + if (!cancelled) setStatus('error') + }) + .finally(() => { + if (!cancelled) setBusy(false) + }) + return () => { + cancelled = true + } + }, [session?.user, code, phase]) if (isPending) { return {t('common.loading')} @@ -120,7 +141,6 @@ export default function DevicePage() { if (status !== 'idle') setStatus('idle') if (value.length < CODE_LENGTH) setPhase('entering') }} - onComplete={() => setPhase('confirming')} /> diff --git a/frontend/src/shared/config/hub.ts b/frontend/src/shared/config/hub.ts new file mode 100644 index 0000000..94d1d1a --- /dev/null +++ b/frontend/src/shared/config/hub.ts @@ -0,0 +1,8 @@ +/** + * pnpm git specifier for the hub bundle. + * + * The bundle lives in `packages/dsh-plugin-hub`, not at the repository root. + * `github:stvlynn/dsh.fish#main` installs the website package (`dsh-fish`), + * which declares no `dsh.bundle`, so the harness activates no layer. + */ +export const HUB_PLUGIN_SPEC = 'github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub' diff --git a/frontend/src/widgets/install-panel/install-panel.tsx b/frontend/src/widgets/install-panel/install-panel.tsx index 62fc927..5e529a9 100644 --- a/frontend/src/widgets/install-panel/install-panel.tsx +++ b/frontend/src/widgets/install-panel/install-panel.tsx @@ -3,11 +3,10 @@ import { AnimatePresence, motion } from 'motion/react' import { AlertTriangle, Check, Copy } from 'lucide-react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/motion/tabs' import type { ArtifactDetail, InstallPlanDto } from '@/entities/artifact/model/types' +import { HUB_PLUGIN_SPEC } from '@/shared/config/hub' import { t } from '@/shared/config/messages' import { cn } from '@/shared/lib/utils' -const HUB_PLUGIN_SPEC = 'github:stvlynn/dsh.fish#main' - /** * The install surface — the reason the site exists. * diff --git a/packages/dsh-plugin-hub/README.md b/packages/dsh-plugin-hub/README.md index 0f58551..6adecce 100644 --- a/packages/dsh-plugin-hub/README.md +++ b/packages/dsh-plugin-hub/README.md @@ -6,9 +6,18 @@ your agent. ## Install ```sh -dsh plugin --profile web add github:stvlynn/dsh.fish#main +dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub ``` +The bundle lives in `packages/dsh-plugin-hub`. Installing the repository root +(`github:stvlynn/dsh.fish#main`) pulls the website package, which is not a +harness bundle and will not load. + +A GitHub topic crawl only reads the repository root `package.json`, so this +bundle is not discovered automatically. Submit it as +`github:stvlynn/dsh.fish/packages/dsh-plugin-hub` (or the same path on npm +later) for it to appear in the catalog. + This package is TypeScript, so a git install runs its `prepare` script to build `lib/`. pnpm ≥10 refuses that until you allow it — copy the package key pnpm prints into your profile's `pnpm-workspace.yaml`: @@ -36,10 +45,10 @@ so a later push cannot change what runs. Reading the catalog needs no account. Signing in attributes installs to you and is required for anything account-shaped later. -`hub_account` with `action: "login"` starts an RFC 8628 device grant: the plugin -requests a code, shows you a URL, and polls until you approve in a browser. The -token is written to `$DSH_HOME/.dsh-fish-token.json` with mode 0600 and is never -logged. +`hub_account` with `action: "login"` starts an RFC 8628 device grant. The first +call returns a short code and a URL — show those to the user. The second call +polls until they approve in a browser. The token is written to +`$DSH_HOME/.dsh-fish-token.json` with mode 0600 and is never logged. A device token is deliberately weaker than a browser session — it can read the catalog and resolve install plans as you, but it cannot submit or claim diff --git a/packages/dsh-plugin-hub/package.json b/packages/dsh-plugin-hub/package.json index c7ea83d..a60bb9b 100644 --- a/packages/dsh-plugin-hub/package.json +++ b/packages/dsh-plugin-hub/package.json @@ -37,7 +37,7 @@ "scripts": { "build": "tsdown", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "prepare": "tsdown" }, "peerDependencies": { diff --git a/packages/dsh-plugin-hub/src/config.test.ts b/packages/dsh-plugin-hub/src/config.test.ts new file mode 100644 index 0000000..1616ddf --- /dev/null +++ b/packages/dsh-plugin-hub/src/config.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { Config, DEFAULT_CONFIG } from './config.js' + +describe('Config schema', () => { + it('implements Standard Schema so Cordis can validate the row', () => { + expect(Config['~standard']?.version).toBe(1) + expect(typeof Config['~standard']?.validate).toBe('function') + }) + + it('fills defaults when the row omits config', () => { + expect(Config['~standard'].validate(undefined)).toEqual({ value: DEFAULT_CONFIG }) + expect(Config['~standard'].validate({})).toEqual({ value: DEFAULT_CONFIG }) + }) + + it('accepts a self-hosted origin and a named profile', () => { + expect( + Config['~standard'].validate({ + baseUrl: 'https://hub.example/', + targetProfile: 'headless', + }), + ).toEqual({ + value: { baseUrl: 'https://hub.example', targetProfile: 'headless' }, + }) + }) + + it('rejects a non-object row', () => { + const result = Config['~standard'].validate('https://dsh.fish') + expect('issues' in result).toBe(true) + }) +}) diff --git a/packages/dsh-plugin-hub/src/config.ts b/packages/dsh-plugin-hub/src/config.ts new file mode 100644 index 0000000..d72b2d2 --- /dev/null +++ b/packages/dsh-plugin-hub/src/config.ts @@ -0,0 +1,71 @@ +/** + * Plugin configuration, as Cordis requires it: a Standard Schema, not a + * plain defaults object. A plain object does not implement `~standard`, so + * the loader cannot validate the row and the plugin fails to start. + * + * Schemastery is the usual authoring API in first-party plugins, but it is + * not installable standalone during the harness developer preview (the same + * constraint as `@deepseek-ai/dsh-tools`). Cordis accepts any Standard Schema + * validator, so this file implements that interface for the two fields the + * plugin actually reads. + */ + +export interface Config { + /** Registry origin. A self-hosted deployment only needs this changed. */ + baseUrl: string + /** + * Profile installs are written into. `current` resolves to the profile this + * harness booted with, which is almost always what a user means. + */ + targetProfile: string +} + +export const DEFAULT_CONFIG: Config = { + baseUrl: 'https://dsh.fish', + targetProfile: 'current', +} + +interface StandardIssue { + readonly message: string +} + +type StandardResult = + | { readonly value: T; readonly issues?: undefined } + | { readonly issues: readonly StandardIssue[] } + +export const Config = { + '~standard': { + version: 1 as const, + vendor: 'dsh-hub', + validate(value: unknown): StandardResult { + if (value === undefined || value === null) { + return { value: { ...DEFAULT_CONFIG } } + } + if (typeof value !== 'object' || Array.isArray(value)) { + return { issues: [{ message: 'config must be an object' }] } + } + const input = value as Record + const baseUrl = readString(input['baseUrl'], 'baseUrl') + if (typeof baseUrl !== 'string') return { issues: [{ message: baseUrl.message }] } + const targetProfile = readString(input['targetProfile'], 'targetProfile') + if (typeof targetProfile !== 'string') { + return { issues: [{ message: targetProfile.message }] } + } + return { + value: { + baseUrl: baseUrl === '' ? DEFAULT_CONFIG.baseUrl : baseUrl.replace(/\/+$/, ''), + targetProfile: targetProfile === '' ? DEFAULT_CONFIG.targetProfile : targetProfile, + }, + } + }, + }, +} + +function readString( + value: unknown, + field: string, +): string | { message: string } { + if (value === undefined) return '' + if (typeof value !== 'string') return { message: `${field} must be a string` } + return value.trim() +} diff --git a/packages/dsh-plugin-hub/src/hub-client.ts b/packages/dsh-plugin-hub/src/hub-client.ts index ecb3a59..3ce9297 100644 --- a/packages/dsh-plugin-hub/src/hub-client.ts +++ b/packages/dsh-plugin-hub/src/hub-client.ts @@ -101,11 +101,23 @@ export class HubClient { /** Step one of the device grant: ask for a code pair. */ async requestDeviceCode(): Promise { - return this.request('/api/auth/device/code', { + const grant = await this.request('/api/auth/device/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ client_id: CLIENT_ID, scope: 'openid profile email' }), }) + return { + ...grant, + verification_uri: absoluteUrl(this.baseUrl, grant.verification_uri), + ...(grant.verification_uri_complete === undefined + ? {} + : { + verification_uri_complete: absoluteUrl( + this.baseUrl, + grant.verification_uri_complete, + ), + }), + } } /** @@ -201,6 +213,15 @@ export class HubClient { } } +/** Resolve a possibly-relative verification URI against the hub origin. */ +export function absoluteUrl(baseUrl: string, uri: string): string { + try { + return new URL(uri).toString() + } catch { + return new URL(uri, `${baseUrl.replace(/\/+$/, '')}/`).toString() + } +} + function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { diff --git a/packages/dsh-plugin-hub/src/index.ts b/packages/dsh-plugin-hub/src/index.ts index 989e078..e46d1f7 100644 --- a/packages/dsh-plugin-hub/src/index.ts +++ b/packages/dsh-plugin-hub/src/index.ts @@ -11,27 +11,21 @@ import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' +import type { Config as HubConfig } from './config.js' import { HubClient, HubError } from './hub-client.js' import { InstallRefused, PlanInstaller } from './installer.js' -import { clearToken, readToken } from './token-store.js' +import { resolveProfile } from './profile.js' +import { + clearPendingGrant, + clearToken, + readPendingGrant, + readToken, + writePendingGrant, +} from './token-store.js' export const name = 'dsh-hub' export const inject = ['tools'] - -export interface Config { - /** Registry origin. A self-hosted deployment only needs this changed. */ - baseUrl: string - /** - * Profile installs are written into. `current` resolves to the profile this - * harness booted with, which is almost always what a user means. - */ - targetProfile: string -} - -export const Config = { - baseUrl: 'https://dsh.fish', - targetProfile: 'current', -} satisfies Config +export { Config } from './config.js' const KINDS = [ 'bundle', @@ -42,7 +36,7 @@ const KINDS = [ 'hook-bridge', ] as const -export function apply(ctx: Context, config: Config = Config): void { +export function apply(ctx: Context, config: HubConfig): void { const baseUrl = config.baseUrl.replace(/\/+$/, '') const client = new HubClient(baseUrl) const profile = resolveProfile(config.targetProfile) @@ -150,6 +144,7 @@ export function apply(ctx: Context, config: Config = Config): void { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: renderInstall(value) }], }, + timeoutMs: 5 * 60 * 1000, async execute(args, exec) { const plan = await client.installPlan({ artifactId: args.artifactId, @@ -180,8 +175,9 @@ export function apply(ctx: Context, config: Config = Config): void { name: 'hub_account', description: 'Sign in to dsh.fish from this machine, or report who is signed in. Signing in uses the ' + - 'OAuth device flow: it returns a short code and a URL for the user to open in a browser. ' + - 'Show both to the user and tell them to approve there — you cannot approve it yourself.', + 'OAuth device flow. The first login call returns a short code and a URL — show both to ' + + 'the user and tell them to approve in a browser. Call login again to wait for that ' + + 'approval. You cannot approve it yourself.', parameters: { action: { type: 'string', @@ -193,6 +189,7 @@ export function apply(ctx: Context, config: Config = Config): void { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: renderAccount(value) }], }, + timeoutMs: 16 * 60 * 1000, async execute(args, exec) { const action = args.action ?? 'status' @@ -212,17 +209,48 @@ export function apply(ctx: Context, config: Config = Config): void { } } - // Login. The grant is returned to the model so it can show the user the - // code and the URL; the poll then blocks until they approve in a browser. - const grant = await client.requestDeviceCode() - ctx.logger?.info?.( - `dsh.fish: open ${grant.verification_uri_complete ?? grant.verification_uri} and enter ${grant.user_code}`, + // Login is two calls because a tool result only reaches the model when + // execute returns. The first call mints a code and returns it so the + // agent can show the user the URL; the second call polls until they + // approve in a browser they already trust. + const pending = await readPendingGrant(baseUrl) + if (pending === undefined) { + const grant = await client.requestDeviceCode() + const verificationUri = grant.verification_uri_complete ?? grant.verification_uri + await writePendingGrant({ + baseUrl, + deviceCode: grant.device_code, + userCode: grant.user_code, + verificationUri, + expiresAt: new Date(Date.now() + grant.expires_in * 1000).toISOString(), + interval: grant.interval, + }) + ctx.logger?.info?.(`dsh.fish: open ${verificationUri} and enter ${grant.user_code}`) + return { + action, + signedIn: false, + status: 'authorization_pending', + userCode: grant.user_code, + verificationUri, + } + } + + const token = await client.pollForToken( + { + device_code: pending.deviceCode, + user_code: pending.userCode, + verification_uri: pending.verificationUri, + expires_in: Math.max(1, Math.floor((Date.parse(pending.expiresAt) - Date.now()) / 1000)), + interval: pending.interval, + }, + exec.signal, ) - const token = await client.pollForToken(grant, exec.signal) + await clearPendingGrant() const me = await client.whoami() return { action, signedIn: true, + status: 'authorized', ...(me.account === null ? {} : { account: me.account.displayName }), obtainedAt: token.obtainedAt, } @@ -231,18 +259,6 @@ export function apply(ctx: Context, config: Config = Config): void { ) } -/** - * `current` means "the profile this process booted with". - * - * The launcher exposes it as `DSH_PROFILE`; without it, `web` is the profile - * `dsh web` auto-initializes, so it is the safest concrete fallback. - */ -function resolveProfile(configured: string): string { - if (configured !== 'current' && configured.trim() !== '') return configured.trim() - const fromEnv = process.env['DSH_PROFILE'] - return fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv.trim() : 'web' -} - function renderSearch(value: unknown): string { const result = value as { total: number; items: { id: string; kind: string; name: string; summary: string; verified: boolean }[] } if (result.items.length === 0) return 'No matching artifacts on dsh.fish.' @@ -299,10 +315,20 @@ function renderInstall(value: unknown): string { } function renderAccount(value: unknown): string { - const state = value as { action: string; signedIn: boolean; account?: string } + const state = value as { + action: string + signedIn: boolean + account?: string + status?: string + userCode?: string + verificationUri?: string + } if (state.action === 'logout') return 'Signed out of dsh.fish on this machine.' + if (state.status === 'authorization_pending' && state.userCode && state.verificationUri) { + return `Open ${state.verificationUri} and enter ${state.userCode}, then run hub_account with action "login" again.` + } if (!state.signedIn) return 'Not signed in to dsh.fish. Run hub_account with action "login".' return `Signed in to dsh.fish as ${state.account ?? 'this account'}.` } -export { HubError, InstallRefused } +export { HubError, InstallRefused, resolveProfile } diff --git a/packages/dsh-plugin-hub/src/installer.test.ts b/packages/dsh-plugin-hub/src/installer.test.ts new file mode 100644 index 0000000..fa99c5f --- /dev/null +++ b/packages/dsh-plugin-hub/src/installer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { composePatchContents } from './installer.js' +import { absoluteUrl } from './hub-client.js' + +describe('composePatchContents', () => { + const row = ['- id: mcp-demo', " name: '@deepseek-ai/dsh-mcp-client'"].join('\n') + + it('writes an insert list into an empty file', () => { + const next = composePatchContents('', 'mcp-demo', row) + expect(next).toContain('# dsh-hub:mcp-demo') + expect(next).toContain('- insert:') + expect(next).toContain(' - id: mcp-demo') + }) + + it('replaces a fresh profile\'s empty array instead of appending after it', () => { + const next = composePatchContents('[]\n', 'mcp-demo', row) + expect(next.startsWith('[]')).toBe(false) + expect(next.trimStart().startsWith('# dsh-hub:mcp-demo')).toBe(true) + }) + + it('appends after an existing user layer', () => { + const existing = ['- insert:', ' - id: already', ' name: other'].join('\n') + const next = composePatchContents(existing, 'mcp-demo', row) + expect(next).toContain('name: other') + expect(next).toContain('# dsh-hub:mcp-demo') + }) +}) + +describe('absoluteUrl', () => { + it('keeps an absolute verification URI', () => { + expect(absoluteUrl('https://dsh.fish', 'https://dsh.fish/device')).toBe( + 'https://dsh.fish/device', + ) + }) + + it('resolves a relative verification URI against the hub origin', () => { + expect(absoluteUrl('https://dsh.fish', '/device')).toBe('https://dsh.fish/device') + }) +}) diff --git a/packages/dsh-plugin-hub/src/installer.ts b/packages/dsh-plugin-hub/src/installer.ts index 5dae897..8d8acfd 100644 --- a/packages/dsh-plugin-hub/src/installer.ts +++ b/packages/dsh-plugin-hub/src/installer.ts @@ -162,8 +162,7 @@ export class PlanInstaller { } } - const block = `\n${marker}\n- insert:\n${indent(rowYaml, 4)}\n` - const next = existing.trimEnd() === '' ? block.trimStart() : `${existing.trimEnd()}\n${block}` + const next = composePatchContents(existing, rowId, rowYaml) await mkdir(dirname(patchPath), { recursive: true }) await writeFile(patchPath, next, 'utf8') @@ -176,6 +175,22 @@ export class PlanInstaller { } } +/** + * Append one hub-owned insert to a profile patch file. + * + * A freshly initialized profile writes `[]` as its user layer. That token is + * an empty YAML array, not a prefix we can append to — concatenating + * `- insert:` after it is not a valid document, and the harness would drop + * the layer. Replace the empty array instead. + */ +export function composePatchContents(existing: string, rowId: string, rowYaml: string): string { + const marker = `# dsh-hub:${rowId}` + const block = `\n${marker}\n- insert:\n${indent(rowYaml, 4)}\n` + const trimmed = existing.trim() + if (trimmed === '' || trimmed === '[]') return block.trimStart() + return `${existing.trimEnd()}\n${block}` +} + /** Refuse a path that escapes its root — a plan is remote input. */ function safeJoin(root: string, relativePath: string): string { const target = resolve(root, normalize(relativePath)) diff --git a/packages/dsh-plugin-hub/src/profile.test.ts b/packages/dsh-plugin-hub/src/profile.test.ts new file mode 100644 index 0000000..396e530 --- /dev/null +++ b/packages/dsh-plugin-hub/src/profile.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { resolveProfile } from './profile.js' + +describe('resolveProfile', () => { + afterEach(() => { + delete process.env['DSH_PROFILE'] + }) + + it('uses an explicit profile name as-is', () => { + expect(resolveProfile('headless')).toBe('headless') + }) + + it('reads DSH_PROFILE when the row asks for the current profile', () => { + process.env['DSH_PROFILE'] = 'demo' + expect(resolveProfile('current')).toBe('demo') + }) + + it('falls back to web, the profile dsh web auto-initializes', () => { + expect(resolveProfile('current')).toBe('web') + expect(resolveProfile('')).toBe('web') + }) +}) diff --git a/packages/dsh-plugin-hub/src/profile.ts b/packages/dsh-plugin-hub/src/profile.ts new file mode 100644 index 0000000..08d7ad0 --- /dev/null +++ b/packages/dsh-plugin-hub/src/profile.ts @@ -0,0 +1,13 @@ +/** + * Resolve the profile `hub_install` writes into. + * + * `current` means "the profile this process booted with". The harness does + * not export that name as a documented environment variable; `DSH_PROFILE` + * is honoured when a user or wrapper sets it. Without it, `web` is the + * profile `dsh web` auto-initializes, so it is the safest concrete fallback. + */ +export function resolveProfile(configured: string): string { + if (configured !== 'current' && configured.trim() !== '') return configured.trim() + const fromEnv = process.env['DSH_PROFILE'] + return fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv.trim() : 'web' +} diff --git a/packages/dsh-plugin-hub/src/token-store.test.ts b/packages/dsh-plugin-hub/src/token-store.test.ts new file mode 100644 index 0000000..d75395f --- /dev/null +++ b/packages/dsh-plugin-hub/src/token-store.test.ts @@ -0,0 +1,71 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + clearToken, + dshHome, + readPendingGrant, + readToken, + writePendingGrant, + writeToken, +} from './token-store.js' + +describe('token store', () => { + let home: string | undefined + + afterEach(async () => { + delete process.env['DSH_HOME'] + if (home !== undefined) await rm(home, { recursive: true, force: true }) + }) + + async function isolateHome(): Promise { + home = await mkdtemp(join(tmpdir(), 'dsh-hub-')) + process.env['DSH_HOME'] = home + return home + } + + it('resolves DSH_HOME the same way the harness does', async () => { + const isolated = await isolateHome() + expect(dshHome()).toBe(isolated) + }) + + it('round-trips a token only for the origin that minted it', async () => { + await isolateHome() + await writeToken({ + accessToken: 'tok_1', + baseUrl: 'https://dsh.fish', + obtainedAt: '2026-01-01T00:00:00.000Z', + }) + expect(await readToken('https://dsh.fish')).toMatchObject({ accessToken: 'tok_1' }) + expect(await readToken('https://hub.example')).toBeUndefined() + }) + + it('drops an expired pending device grant', async () => { + const isolated = await isolateHome() + await writePendingGrant({ + baseUrl: 'https://dsh.fish', + deviceCode: 'dev', + userCode: '12345678', + verificationUri: 'https://dsh.fish/device', + expiresAt: new Date(Date.now() - 1000).toISOString(), + interval: 5, + }) + expect(await readPendingGrant('https://dsh.fish')).toBeUndefined() + await expect(readFile(join(isolated, '.dsh-fish-device-pending.json'))).rejects.toThrow() + }) + + it('clears the pending grant together with the token', async () => { + const isolated = await isolateHome() + await writePendingGrant({ + baseUrl: 'https://dsh.fish', + deviceCode: 'dev', + userCode: '12345678', + verificationUri: 'https://dsh.fish/device', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + interval: 5, + }) + await clearToken() + await expect(readFile(join(isolated, '.dsh-fish-device-pending.json'))).rejects.toThrow() + }) +}) diff --git a/packages/dsh-plugin-hub/src/token-store.ts b/packages/dsh-plugin-hub/src/token-store.ts index 9bf2de1..51b5f28 100644 --- a/packages/dsh-plugin-hub/src/token-store.ts +++ b/packages/dsh-plugin-hub/src/token-store.ts @@ -20,10 +20,29 @@ export function dshHome(): string { return join(homedir(), '.dsh') } +export interface StoredPendingGrant { + readonly baseUrl: string + readonly deviceCode: string + readonly userCode: string + readonly verificationUri: string + readonly expiresAt: string + readonly interval: number +} + function tokenPath(): string { return join(dshHome(), '.dsh-fish-token.json') } +function pendingPath(): string { + return join(dshHome(), '.dsh-fish-device-pending.json') +} + +async function writeSecret(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }) + await chmod(path, 0o600) +} + /** * Persisted device-grant token. * @@ -45,13 +64,34 @@ export async function readToken(baseUrl: string): Promise { - const path = tokenPath() - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, `${JSON.stringify(token, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }) - // `mode` on writeFile only applies at creation; chmod covers an existing file. - await chmod(path, 0o600) + await writeSecret(tokenPath(), token) } export async function clearToken(): Promise { await rm(tokenPath(), { force: true }) + await rm(pendingPath(), { force: true }) +} + +export async function readPendingGrant(baseUrl: string): Promise { + try { + const raw = await readFile(pendingPath(), 'utf8') + const parsed = JSON.parse(raw) as StoredPendingGrant + if (parsed.baseUrl !== baseUrl) return undefined + if (typeof parsed.deviceCode !== 'string' || parsed.deviceCode === '') return undefined + if (Date.parse(parsed.expiresAt) <= Date.now()) { + await clearPendingGrant() + return undefined + } + return parsed + } catch { + return undefined + } +} + +export async function writePendingGrant(grant: StoredPendingGrant): Promise { + await writeSecret(pendingPath(), grant) +} + +export async function clearPendingGrant(): Promise { + await rm(pendingPath(), { force: true }) }