Merge remote-tracking branch 'origin/master' into feat/loader-entry-disabled-interpolation
This commit is contained in:
@@ -113,20 +113,42 @@ describe('E2B e2e workflow', () => {
|
||||
})
|
||||
|
||||
describe('Issue lifecycle workflow', () => {
|
||||
it('uses review signals instead of rerunning when a draft becomes ready', () => {
|
||||
it('uses explicit review handoff events without rerunning when a draft becomes ready', () => {
|
||||
const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
|
||||
const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request')
|
||||
const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review')
|
||||
const lifecycleJob = workflowJob(lifecycle, 'lifecycle')
|
||||
const policy = loadWorkflow('.github/workflows/issue-policy.yml')
|
||||
const policyPullRequest = workflowEvent(policy, 'pull_request')
|
||||
|
||||
expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
|
||||
expect(lifecyclePullRequest.types).toContain('review_requested')
|
||||
expect(lifecycleReview.types).toContain('submitted')
|
||||
expect(lifecycleReview.types).toEqual(['submitted'])
|
||||
expect(lifecycleJob.if).toBe(
|
||||
"${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}",
|
||||
)
|
||||
expect(policyPullRequest.types).toContain('ready_for_review')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Git hooks', () => {
|
||||
it('leaves frozen Agent Note sidecars to the archive verifier', () => {
|
||||
const lefthook = loadWorkflow('lefthook.yml')
|
||||
|
||||
for (const hookName of ['pre-commit', 'pre-merge-commit']) {
|
||||
const hook = lefthook[hookName]
|
||||
if (!isRecord(hook) || !Array.isArray(hook.jobs)) {
|
||||
throw new TypeError(`lefthook must define ${hookName} jobs`)
|
||||
}
|
||||
const pairing: unknown = hook.jobs.find(
|
||||
(job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)',
|
||||
)
|
||||
|
||||
expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function loadWorkflow(path: string): Record<string, unknown> {
|
||||
const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8'))
|
||||
if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`)
|
||||
@@ -140,6 +162,13 @@ function workflowEvent(workflow: Record<string, unknown>, event: string): Record
|
||||
return workflow.on[event]
|
||||
}
|
||||
|
||||
function workflowJob(workflow: Record<string, unknown>, job: string): Record<string, unknown> {
|
||||
if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs[job])) {
|
||||
throw new TypeError(`workflow must define the ${job} job`)
|
||||
}
|
||||
return workflow.jobs[job]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
httpServer: 'http-server.md',
|
||||
invariants: 'invariants.md',
|
||||
llm: 'llm-streaming.md',
|
||||
messageFeedback: 'feedback.md',
|
||||
permission: 'permission.md',
|
||||
planMode: 'plan.md',
|
||||
pty: 'pty.md',
|
||||
@@ -229,6 +230,25 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ResolvedRetryPolicy: 'llm-streaming.md',
|
||||
Message: 'llm-streaming.md',
|
||||
MessageSource: 'llm-streaming.md',
|
||||
MessageFeedbackDeleteRequest: 'feedback.md',
|
||||
MessageFeedbackDeleteResult: 'feedback.md',
|
||||
MessageFeedbackDeleteValue: 'feedback.md',
|
||||
MessageFeedbackFailure: 'feedback.md',
|
||||
MessageFeedbackItem: 'feedback.md',
|
||||
MessageFeedbackListRequest: 'feedback.md',
|
||||
MessageFeedbackListResult: 'feedback.md',
|
||||
MessageFeedbackListValue: 'feedback.md',
|
||||
MessageFeedbackNoteBlank: 'feedback.md',
|
||||
MessageFeedbackNoteTooLarge: 'feedback.md',
|
||||
MessageFeedbackPutRequest: 'feedback.md',
|
||||
MessageFeedbackPutResult: 'feedback.md',
|
||||
MessageFeedbackRating: 'feedback.md',
|
||||
MessageFeedbackRejected: 'feedback.md',
|
||||
MessageFeedbackSessionNotFound: 'feedback.md',
|
||||
MessageFeedbackSuccess: 'feedback.md',
|
||||
MessageFeedbackTargetNotFound: 'feedback.md',
|
||||
MessageFeedbackVersion: 'feedback.md',
|
||||
MessageFeedbackVersionConflict: 'feedback.md',
|
||||
UserMessage: 'session.md',
|
||||
PreStepDecision: 'core.md',
|
||||
PreStepContext: 'core.md',
|
||||
@@ -384,6 +404,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
TaskRead: 'tasks.md',
|
||||
TaskSnapshot: 'tasks.md',
|
||||
TaskStart: 'tasks.md',
|
||||
TasksChangedListener: 'tasks.md',
|
||||
TokenMeasurement: 'token-meter.md',
|
||||
CodeDispatchLog: 'tools.md',
|
||||
PostToolDecision: 'tools.md',
|
||||
@@ -462,6 +483,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'Promise',
|
||||
'Record',
|
||||
'Readonly',
|
||||
'Uint8Array',
|
||||
])
|
||||
|
||||
/** Project types deliberately documented outside the subsystems catalog. */
|
||||
|
||||
@@ -135,7 +135,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'session',
|
||||
title: 'In-memory session store',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'],
|
||||
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'],
|
||||
note: 'Owns append-only Session instances and emits the durable session event feed.',
|
||||
},
|
||||
{
|
||||
@@ -167,7 +167,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Durable session persistence seam',
|
||||
mode: 'seam',
|
||||
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'],
|
||||
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
|
||||
},
|
||||
{
|
||||
@@ -211,9 +211,16 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'storage-domain',
|
||||
title: 'Domain data facility',
|
||||
mode: 'core',
|
||||
consumers: ['workspace'],
|
||||
consumers: ['workspace', 'message-feedback'],
|
||||
note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.',
|
||||
},
|
||||
{
|
||||
key: 'messageFeedback',
|
||||
pkg: 'message-feedback',
|
||||
title: 'Lifecycle-bound message feedback',
|
||||
mode: 'core',
|
||||
note: 'Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry.',
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
pkg: 'workspace',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts'
|
||||
|
||||
/** The fenced-block info string for generated declaration blocks (skipped by
|
||||
* doc-typecheck, since their imported types are not standalone-compilable). */
|
||||
@@ -359,7 +360,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
'## Event envelope',
|
||||
'',
|
||||
@@ -382,31 +383,79 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the catalog, `--check` fails if the committed copy
|
||||
/**
|
||||
* Render the runtime known-vocabulary module: every event type the packages in
|
||||
* this repo can write, as a generated `ReadonlySet` the read path checks
|
||||
* unknown-type refusal against (`SessionEvent.ignorable` contract).
|
||||
*/
|
||||
export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string {
|
||||
const names = [...new Set(events.map(e => e.name))].sort()
|
||||
return [
|
||||
'/**',
|
||||
' * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run',
|
||||
' * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by',
|
||||
' * `pnpm run verify-persistence-catalog`, part of `doc-sync`).',
|
||||
' * @module @deepseek-ai/dsh-session/known-event-types',
|
||||
' */',
|
||||
'',
|
||||
'/**',
|
||||
' * Every `SessionEventMap` member declared in this repository — the event',
|
||||
' * vocabulary this build understands. The persistence read path refuses to',
|
||||
' * interpret a log containing a type outside this set unless the event',
|
||||
' * carries the envelope\'s `ignorable` marker (see `SessionEvent.ignorable`',
|
||||
' * in `./types.ts`): such a log was likely written by a newer harness, and',
|
||||
' * silently skipping a required event would reconstruct a wrong session.',
|
||||
' * Downstream (out-of-repo) plugin events are outside this list by',
|
||||
' * construction; a registration surface for them is deferred until such a',
|
||||
' * consumer exists.',
|
||||
' */',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([',
|
||||
...names.map(name => ` '${name}',`),
|
||||
'])',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** One generated artifact: repo-relative target and its freshly-rendered content. */
|
||||
interface GeneratedArtifact {
|
||||
readonly out: string
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the artifacts, `--check` fails if a committed copy
|
||||
* is stale. Guarded behind an entry-point check so importing this module for
|
||||
* tests neither regenerates the committed file nor calls process.exit. */
|
||||
* tests neither regenerates the committed files nor calls process.exit. */
|
||||
function main(): void {
|
||||
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes())
|
||||
const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes())
|
||||
const artifacts: GeneratedArtifact[] = [
|
||||
{ out: OUT, content: render(events, collectEventEnvelopeTypes()) },
|
||||
{ out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
|
||||
]
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, OUT), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces. Either way the remedy is the
|
||||
// same — regenerate — so treat a read failure as "stale".
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
console.log(`gen-persistence-catalog: ${OUT} is up to date.`)
|
||||
const stale = artifacts.filter((artifact) => {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, artifact.out), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces. Either way the remedy is the
|
||||
// same — regenerate — so treat a read failure as "stale".
|
||||
committed = null
|
||||
}
|
||||
return committed !== artifact.content
|
||||
})
|
||||
if (stale.length === 0) {
|
||||
console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`)
|
||||
console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(resolve(root, OUT), content)
|
||||
console.log(`gen-persistence-catalog: wrote ${OUT}.`)
|
||||
for (const artifact of artifacts) {
|
||||
writeFileSync(resolve(root, artifact.out), artifact.content)
|
||||
console.log(`gen-persistence-catalog: wrote ${artifact.out}.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
|
||||
@@ -27,8 +27,8 @@ const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'p
|
||||
* root manifest), test infrastructure, the documentation site, the runnable
|
||||
* demo leaves, and the native launcher's build workspace. A runtime
|
||||
* declaration by anything outside these areas is a disclosure-relevant
|
||||
* runtime dependency, because `scripts/install.sh` installs the repository
|
||||
* itself and any plugin package can be mounted from a user's `cordis.yml`.
|
||||
* runtime dependency because any plugin package can be mounted from a user's
|
||||
* `cordis.yml`.
|
||||
*/
|
||||
const DEV_ONLY_AREAS = [
|
||||
'package.json',
|
||||
@@ -369,7 +369,7 @@ function collectNpmDeps(): ExternalDep[] {
|
||||
*/
|
||||
export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
|
||||
const tiers = new Map<string, boolean>()
|
||||
// `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook.
|
||||
// `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook.
|
||||
tiers.set('tsx', true)
|
||||
for (const [path, manifest] of manifests) {
|
||||
const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area))
|
||||
@@ -707,7 +707,7 @@ ${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.u
|
||||
|
||||
## Runtime npm dependencies
|
||||
|
||||
External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
|
||||
External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
|
||||
|
||||
${renderNpmTable(runtimeDeps)}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
|
||||
import WebService from '@deepseek-ai/dsh-web'
|
||||
@@ -60,6 +62,29 @@ import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
|
||||
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
/** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */
|
||||
class CatalogAttachmentStore extends AttachmentStore {
|
||||
readonly imageLimits: ImageAttachmentLimits = Object.freeze({
|
||||
maxImageBytes: 1,
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: 1,
|
||||
maxImagePixels: 1,
|
||||
mediaTypes: Object.freeze(['image/png'] as const),
|
||||
})
|
||||
|
||||
override validateImage(_input: SaveImageAttachment): Promise<void> {
|
||||
return Promise.reject(new Error('gen-tool-catalog: attachment validation is unreachable during schema harvest'))
|
||||
}
|
||||
|
||||
override saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
return Promise.reject(new Error('gen-tool-catalog: attachment writes are unreachable during schema harvest'))
|
||||
}
|
||||
|
||||
override readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
return Promise.reject(new Error('gen-tool-catalog: attachment reads are unreachable during schema harvest'))
|
||||
}
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog.md'
|
||||
|
||||
@@ -265,16 +290,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-fs',
|
||||
dir: 'tool-fs',
|
||||
source: 'packages/fs/tool-fs/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'],
|
||||
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt', 'ctx.attachments (read_image registration)', 'ctx.llm + an image-capable route (read_image execution)'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image)', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tool needs `fs`; the bare provider is sufficient because policy
|
||||
// changes behavior, not schema shape.
|
||||
// changes behavior, not schema shape. The catalog seam marker opts into
|
||||
// the attachments-conditional read_image schema without attachment I/O.
|
||||
await ctx.plugin(LocalFileSystem)
|
||||
await ctx.plugin(CatalogAttachmentStore)
|
||||
await ctx.plugin(ToolFs)
|
||||
},
|
||||
note:
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs-search',
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
#!/bin/sh
|
||||
# dsh one-line installer.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/scripts/install.sh | sh
|
||||
#
|
||||
# It clones the harness under ~/.dsh/source (the master clone at
|
||||
# ~/.dsh/source/master), adds a per-install staging worktree at
|
||||
# ~/.dsh/source/staging-<timestamp> on branch dsh-staging/<timestamp>, checks
|
||||
# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs
|
||||
# `pnpm install`, points the stable `~/.dsh/source/current` symlink
|
||||
# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`,
|
||||
# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot,
|
||||
# builds the repository artifacts, and launches the Web UI. Keeping every
|
||||
# checkout under ~/.dsh/source keeps successive
|
||||
# upgrades in one place instead of scattered sibling clones, and lets staging
|
||||
# worktrees share the master clone's object store. The PATH symlink resolves through
|
||||
# `current`, so an upgrade repoints one stable symlink instead of relinking PATH:
|
||||
# the `dsh` on PATH never moves and can never dangle.
|
||||
#
|
||||
# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
|
||||
# than `curl ... | sh`) it never clones and never touches that working tree;
|
||||
# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse
|
||||
# --git-common-dir` resolves the repository behind it (for a linked worktree that
|
||||
# is the real clone, not the worktree), and a fresh staging worktree branched
|
||||
# from the checkout's HEAD lands in the source container beside `current`. The
|
||||
# container owns staging worktrees and `current`; the clone is discovered, not
|
||||
# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one
|
||||
# layout and stay upgradable. Adoption carries committed work only: the staging
|
||||
# worktree branches from HEAD, so uncommitted changes stay in the checkout.
|
||||
# Setting DSH_SOURCE to a different directory opts back into the normal
|
||||
# clone/worktree path.
|
||||
#
|
||||
# Adopting an arbitrary clone leaves the container not self-contained: its
|
||||
# staging worktrees hold an absolute gitdir pointer into that clone, so deleting
|
||||
# it breaks them. `git worktree list` in that clone is the record of which
|
||||
# worktrees depend on it.
|
||||
#
|
||||
# When run through `curl | sh` the script text arrives on stdin, so every
|
||||
# prompt and the final launch read the controlling terminal (/dev/tty) directly;
|
||||
# with no terminal the script prints the manual next steps instead.
|
||||
#
|
||||
# Overridable via environment:
|
||||
# DSH_REF branch or tag to clone/checkout (default: master)
|
||||
# DSH_REPO clone URL (default: the GitHub repo)
|
||||
# DSH_SOURCE source container directory (default: ~/.dsh/source)
|
||||
# DSH_MASTER master clone directory (default: $DSH_SOURCE/master)
|
||||
# DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current)
|
||||
# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin)
|
||||
# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh)
|
||||
set -eu
|
||||
|
||||
DSH_REF=${DSH_REF:-master}
|
||||
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-ai/deepseek-harness-sdk.git}
|
||||
# DSH_SOURCE is the staging-worktree container and the default home of `current`.
|
||||
# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE,
|
||||
# while adoption discovers an existing clone anywhere on disk. Remember whether
|
||||
# DSH_SOURCE was explicit so a different path selects clone mode.
|
||||
if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
|
||||
DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
|
||||
DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master}
|
||||
# The stable symlink the PATH launcher resolves through: PATH/dsh ->
|
||||
# current/bin/dsh -> <staging>/bin/dsh. Installs and upgrades repoint `current`;
|
||||
# the PATH target remains current/bin/dsh.
|
||||
DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current}
|
||||
DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
|
||||
# One UTC basic timestamp names this install's staging branch and worktree.
|
||||
DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP
|
||||
DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP
|
||||
|
||||
# --- path helpers ---------------------------------------------------------------
|
||||
# Every path comparison below runs on physical paths. Git always reports resolved
|
||||
# paths, so comparing one against an unresolved path disagrees whenever a symlink
|
||||
# sits anywhere above the checkout — a symlinked home directory is enough, and
|
||||
# macOS reaches every mktemp path that way through /var -> private/var. The
|
||||
# mismatch silently misclassifies an existing managed install as a foreign clone
|
||||
# and builds a second container beside the real one.
|
||||
# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+.
|
||||
#
|
||||
# A not-yet-created directory (the container on a fresh install) has no physical
|
||||
# path. Falling back here rather than at each call site keeps every caller a
|
||||
# plain assignment, so no site can compare against an empty path by forgetting
|
||||
# its own fallback.
|
||||
resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; }
|
||||
|
||||
# --- in-repo detection ---------------------------------------------------------
|
||||
# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
|
||||
# name and no file path resolves; running a checked-out copy (`sh
|
||||
# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
|
||||
# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
|
||||
# this is in-repo mode: never clone, never touch that working tree. An explicit
|
||||
# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path.
|
||||
IN_REPO=0
|
||||
DSH_CHECKOUT=''
|
||||
if [ -f "$0" ]; then
|
||||
_self_dir=$(resolve_dir "$(dirname -- "$0")")
|
||||
if [ -n "$_self_dir" ]; then
|
||||
# Physical without its own resolve_dir: dirname is textual, so trimming a
|
||||
# resolved path leaves one. The comparison below depends on that.
|
||||
_repo_root=$(dirname -- "$_self_dir")
|
||||
if [ "$(basename -- "$_self_dir")" = scripts ] \
|
||||
&& [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
|
||||
# Compare the explicit DSH_SOURCE physically: an unresolved but equivalent
|
||||
# path must still count as "the caller meant this checkout".
|
||||
_src_resolved=$(resolve_dir "$DSH_SOURCE")
|
||||
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then
|
||||
IN_REPO=1
|
||||
DSH_CHECKOUT=$_repo_root
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- terminal-aware prompting --------------------------------------------------
|
||||
# stdin is the piped script, so read the controlling terminal for input.
|
||||
if { true </dev/tty; } 2>/dev/null; then
|
||||
HAS_TTY=1
|
||||
# Restore terminal echo on exit or interrupt: ask_secret disables echo between
|
||||
# its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the
|
||||
# shell is killed by a signal, so the fatal signals need their own handler. A
|
||||
# successful run ends in exec, which replaces this process and drops the traps.
|
||||
trap 'stty echo </dev/tty 2>/dev/null || true' EXIT
|
||||
trap 'stty echo </dev/tty 2>/dev/null || true; exit 130' INT TERM HUP
|
||||
else
|
||||
HAS_TTY=0
|
||||
fi
|
||||
|
||||
# Colour only when writing to a terminal.
|
||||
if [ -t 1 ]; then
|
||||
B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m')
|
||||
GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m')
|
||||
else
|
||||
B=''; DIM=''; RED=''; GRN=''; YEL=''; RST=''
|
||||
fi
|
||||
|
||||
info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; }
|
||||
step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; }
|
||||
warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; }
|
||||
die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; }
|
||||
|
||||
# ask PROMPT [DEFAULT] -> answer on stdout (plain-text line).
|
||||
ask() {
|
||||
[ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
|
||||
printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
|
||||
IFS= read -r _ans </dev/tty || _ans=''
|
||||
[ -n "$_ans" ] || _ans=${2:-}
|
||||
printf '%s' "$_ans"
|
||||
}
|
||||
|
||||
# ask_secret PROMPT -> answer on stdout, with terminal echo suppressed.
|
||||
ask_secret() {
|
||||
[ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
|
||||
printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
|
||||
stty -echo </dev/tty 2>/dev/null || true
|
||||
IFS= read -r _sec </dev/tty || _sec=''
|
||||
stty echo </dev/tty 2>/dev/null || true
|
||||
printf '\n' >/dev/tty
|
||||
printf '%s' "$_sec"
|
||||
}
|
||||
|
||||
# confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y".
|
||||
confirm() {
|
||||
_def=${2:-N}
|
||||
if [ "$HAS_TTY" != 1 ]; then
|
||||
[ "$_def" = Y ] # non-interactive: take the default
|
||||
return
|
||||
fi
|
||||
if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi
|
||||
printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty
|
||||
IFS= read -r _r </dev/tty || _r=''
|
||||
[ -n "$_r" ] || _r=$_def
|
||||
case "$_r" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
printf '%scheckout %s%s\n' "$DIM" "$DSH_CHECKOUT" "$RST"
|
||||
else
|
||||
printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST"
|
||||
printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST"
|
||||
printf '%scurrent %s%s\n' "$DIM" "$DSH_CURRENT" "$RST"
|
||||
fi
|
||||
|
||||
# --- 1. dependency check -------------------------------------------------------
|
||||
step "Checking dependencies"
|
||||
|
||||
command -v git >/dev/null 2>&1 || die "git is required but not found. Install git, then re-run."
|
||||
info "git ... ok"
|
||||
|
||||
# Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field).
|
||||
node_ok() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
_v=$(node -v 2>/dev/null) || return 1
|
||||
_v=${_v#v}
|
||||
_major=${_v%%.*}
|
||||
_rest=${_v#*.}
|
||||
_minor=${_rest%%.*}
|
||||
case "$_major" in ''|*[!0-9]*) return 1 ;; esac
|
||||
case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac
|
||||
[ "$_major" -ge 24 ] && return 0
|
||||
[ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0
|
||||
return 1
|
||||
}
|
||||
if node_ok; then
|
||||
info "node $(node -v) ... ok"
|
||||
else
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run."
|
||||
fi
|
||||
die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run."
|
||||
fi
|
||||
|
||||
# pnpm is the only dependency we offer to install for you.
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
info "pnpm $(pnpm --version) ... ok"
|
||||
else
|
||||
warn "pnpm is not installed."
|
||||
if confirm "Install pnpm now?" Y; then
|
||||
if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then
|
||||
info "enabled pnpm via corepack"
|
||||
elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then
|
||||
info "installed pnpm via npm"
|
||||
else
|
||||
die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run."
|
||||
fi
|
||||
command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run."
|
||||
else
|
||||
die "pnpm is required. Install it (https://pnpm.io/installation), then re-run."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. resolve the repository and lay out the staging worktree ---------------
|
||||
# The source container owns staging worktrees and `current`; the repository is
|
||||
# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER;
|
||||
# in-repo adoption discovers it from the checkout. Both then run one shared
|
||||
# worktree/exclude/lock path, so an arbitrary clone and a managed install
|
||||
# converge on the same layout.
|
||||
#
|
||||
# REPO_COMMON is the shared git directory every worktree of the repository
|
||||
# points at; REPO_ROOT is the working tree that owns it (the master clone).
|
||||
REPO_COMMON=''
|
||||
REPO_ROOT=''
|
||||
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
step "Using existing checkout at $DSH_CHECKOUT"
|
||||
info "running from inside the repo — never cloning, and DSH_REF is ignored"
|
||||
|
||||
# Resolve the repository behind the checkout. --git-common-dir returns the
|
||||
# SHARED git dir, so a linked worktree resolves to the real clone rather than
|
||||
# itself; it is relative for a plain clone, so anchor it before resolving.
|
||||
# Require the resolved git dir to exist: resolve_dir echoes its argument back
|
||||
# for a missing path, so test the directory rather than the returned string.
|
||||
if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then
|
||||
case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac
|
||||
[ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common")
|
||||
fi
|
||||
[ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it."
|
||||
REPO_ROOT=$(dirname -- "$REPO_COMMON")
|
||||
|
||||
# Reuse the container when the repository already lives inside it (the normal
|
||||
# managed install re-running its own script); otherwise treat that clone as
|
||||
# its own master and keep worktrees in the default container.
|
||||
_src_resolved=$(resolve_dir "$DSH_SOURCE")
|
||||
case "$REPO_ROOT/" in
|
||||
"$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;;
|
||||
*) info "adopting clone $REPO_ROOT as its own master" ;;
|
||||
esac
|
||||
DSH_MASTER=$REPO_ROOT
|
||||
else
|
||||
step "Fetching source into $DSH_MASTER"
|
||||
if [ -d "$DSH_MASTER/.git" ]; then
|
||||
info "existing master clone found — updating"
|
||||
git -C "$DSH_MASTER" fetch origin "$DSH_REF"
|
||||
# Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
|
||||
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
|
||||
# the re-run idempotent whether or not DSH_REF changed since the last install.
|
||||
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
|
||||
else
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
|
||||
fi
|
||||
# Physical on both branches: REPO_ROOT is compared against resolved paths
|
||||
# below, and REPO_COMMON stays symmetric with it so neither can be read as
|
||||
# carrying a different kind of path.
|
||||
REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git")
|
||||
REPO_ROOT=$(resolve_dir "$DSH_MASTER")
|
||||
fi
|
||||
|
||||
step "Adding staging worktree at $DSH_STAGING"
|
||||
[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run."
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
# The staging worktree owns the branch dsh runs from; the repository stays as
|
||||
# the fetch/upgrade base and is never a launcher target. A clone install
|
||||
# branches from the ref it just fetched; adoption branches from the checkout's
|
||||
# HEAD so the contributor's committed work is what runs.
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
else
|
||||
git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
|
||||
|| git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
fi
|
||||
# Exclude the per-worktree merge lock in the shared git dir's info/exclude,
|
||||
# which every linked worktree inherits.
|
||||
_exclude="$REPO_COMMON/info/exclude"
|
||||
if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then
|
||||
printf '.agents/merge.lock\n' >>"$_exclude"
|
||||
fi
|
||||
mkdir -p "$DSH_STAGING/.agents"
|
||||
: >"$DSH_STAGING/.agents/merge.lock"
|
||||
|
||||
# --- 3. install dependencies (no build; the launcher runs from source) --------
|
||||
step "Installing dependencies with pnpm (this can take a while)"
|
||||
( cd "$DSH_STAGING" && pnpm install )
|
||||
|
||||
[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
|
||||
|
||||
# --- 4. put `dsh` on PATH ------------------------------------------------------
|
||||
# Every install goes through a stable `current` symlink so an upgrade repoints
|
||||
# one symlink (current -> new worktree) and the PATH launcher never moves:
|
||||
# PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh.
|
||||
step "Linking dsh into $DSH_BIN_DIR"
|
||||
mkdir -p "$DSH_BIN_DIR"
|
||||
# The launcher must resolve to a staging worktree, never to the repository
|
||||
# itself: an upgrade repoints `current`, so aliasing it onto the master clone
|
||||
# would make every upgrade rewrite the fetch/upgrade base. Compare physical
|
||||
# paths — a symlinked or unresolved path would slip past a string compare.
|
||||
_staging_resolved=$(resolve_dir "$DSH_STAGING")
|
||||
[ "$_staging_resolved" = "$REPO_ROOT" ] \
|
||||
&& die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree."
|
||||
# Point `current` at this staging worktree with `ln -sfn`: -f replaces an
|
||||
# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
|
||||
# an existing symlink-to-directory and dropping the new link *inside* the old
|
||||
# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
|
||||
# symlink the same way. The swap is one unlink+symlink pair on a local fs; the
|
||||
# installer holds no other process racing this path.
|
||||
ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
|
||||
info "pointed $DSH_CURRENT -> $DSH_STAGING"
|
||||
DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
|
||||
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
|
||||
*) ON_PATH=0 ;;
|
||||
esac
|
||||
if [ "$ON_PATH" = 0 ]; then
|
||||
warn "$DSH_BIN_DIR is not on your PATH."
|
||||
_line="export PATH=\"$DSH_BIN_DIR:\$PATH\""
|
||||
_rc=''
|
||||
_sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash.
|
||||
case "${_sh##*/}" in
|
||||
zsh) _rc="$HOME/.zshrc" ;;
|
||||
bash) _rc="$HOME/.bashrc" ;;
|
||||
esac
|
||||
if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then
|
||||
info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up"
|
||||
elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then
|
||||
printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc"
|
||||
info "updated $_rc — run 'source $_rc' or open a new shell to pick it up"
|
||||
else
|
||||
warn "add this line to your shell profile yourself:"
|
||||
printf ' %s\n' "$_line"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 5. credentials ------------------------------------------------------------
|
||||
# Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them.
|
||||
if [ -n "${DSH_HOME:-}" ]; then
|
||||
CONF="$DSH_HOME"
|
||||
else
|
||||
CONF="$HOME/.dsh"
|
||||
fi
|
||||
ENV_FILE="$CONF/.env"
|
||||
|
||||
step "Configuring credentials"
|
||||
if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then
|
||||
info "DEEPSEEK_API_KEY already set in $ENV_FILE"
|
||||
if ! confirm "Replace it?" N; then
|
||||
SKIP_CREDS=1
|
||||
fi
|
||||
fi
|
||||
if [ "${SKIP_CREDS:-0}" != 1 ]; then
|
||||
if [ "$HAS_TTY" = 1 ]; then
|
||||
API_KEY=$(ask_secret "DeepSeek API key (input hidden):")
|
||||
if [ -z "$API_KEY" ]; then
|
||||
warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
|
||||
else
|
||||
BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):")
|
||||
mkdir -p "$CONF"
|
||||
# The installer owns exactly the two DEEPSEEK_* lines; any other lines the
|
||||
# user keeps in this .env are preserved. The rewrite happens in a subshell
|
||||
# so umask 077 (which closes the create-time permission race) does not leak
|
||||
# into the exec'd dsh, and lands atomically via a same-dir temp + mv.
|
||||
_tmp="$ENV_FILE.dsh.$$"
|
||||
(
|
||||
umask 077
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true
|
||||
else
|
||||
: >"$_tmp"
|
||||
fi
|
||||
printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp"
|
||||
if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi
|
||||
)
|
||||
mv "$_tmp" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
info "wrote $ENV_FILE"
|
||||
fi
|
||||
else
|
||||
warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 6. build and launch the Web interface -------------------------------------
|
||||
step "Done"
|
||||
if [ "$HAS_TTY" = 1 ]; then
|
||||
step "Building DeepSeek Harness for Web UI"
|
||||
( cd "$DSH_STAGING" && pnpm run build )
|
||||
info "launching Web UI — run 'dsh web' anytime to start again"
|
||||
exec "$DSH_BIN_DIR/dsh" web </dev/tty
|
||||
else
|
||||
info "install complete. Build and start the Web UI with:"
|
||||
printf ' (cd %s && pnpm run build)\n' "$DSH_STAGING"
|
||||
printf ' %s web\n' "$DSH_BIN_DIR/dsh"
|
||||
fi
|
||||
@@ -104,7 +104,7 @@ describe('rewriteMarkdown', () => {
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[B](./reference/b.md#part) '
|
||||
+ '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) '
|
||||
+ '[source](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
|
||||
+ '[web](https://example.com)\n',
|
||||
)
|
||||
})
|
||||
@@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => {
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe('\n')
|
||||
})).toBe('\n')
|
||||
})
|
||||
|
||||
it('hands an image to the placer and uses the URL it returns', () => {
|
||||
@@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => {
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[title](./reference/b.md "b.md") '
|
||||
+ '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n',
|
||||
+ '[escaped](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/docs/x(y).md)\n',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
|
||||
|
||||
const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk'
|
||||
const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const generatedRoot = resolve(root, 'website/.generated')
|
||||
|
||||
@@ -209,7 +209,7 @@ function githubTarget(
|
||||
image: boolean,
|
||||
): string {
|
||||
const path = repoPath(absPath, repoRoot)
|
||||
if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}`
|
||||
if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/${repositoryRef}/${path}${suffix}`
|
||||
const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
|
||||
const lineSuffix = line === undefined ? suffix : `#L${line}`
|
||||
return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1620,6 +1620,11 @@
|
||||
"symbol": "WebBootGraph",
|
||||
"source": "packages/client/modules/src/client/manifest.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/telemetry.md",
|
||||
"symbol": "TelemetrySharingStatus",
|
||||
"source": "packages/session/session-telemetry/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/telemetry.md",
|
||||
"symbol": "TelemetrySeverity",
|
||||
@@ -1734,6 +1739,101 @@
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "AgentOptions",
|
||||
"source": "packages/core/agent/src/runtime-types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackVersion",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackRating",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackItem",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackListRequest",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackListValue",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackPutRequest",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackDeleteRequest",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackDeleteValue",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackSessionNotFound",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackTargetNotFound",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackVersionConflict",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackNoteBlank",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackNoteTooLarge",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackFailure",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackSuccess",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackRejected",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackListResult",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackPutResult",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackDeleteResult",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -72,6 +72,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
|
||||
'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-task': { kind: 'none', reason: 'Browser-side read-only projection of ctx.tasks records; dsh-tool-tasks owns the model-facing surface.' },
|
||||
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
|
||||
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' },
|
||||
|
||||
@@ -1,60 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findInternalRepositoryReferences } from './verify-public-repository-links.ts'
|
||||
import { findUnavailableRepositoryReferences } from './verify-public-repository-links.ts'
|
||||
|
||||
describe('public repository link policy', () => {
|
||||
it('rejects encoded and case-varied internal identities without blocking public repositories', () => {
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F')
|
||||
const htmlEncodedRepository = internalRepository.replace('/', '/')
|
||||
const jsonEscapedRepository = internalRepository.replace('/', '\\/')
|
||||
const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`)
|
||||
describe('repository link policy', () => {
|
||||
it('rejects encoded and case-varied references to the unavailable repository', () => {
|
||||
const unavailableOwner = ['deepseek', 'ai'].join('-')
|
||||
const unavailableName = ['deepseek', 'harness', 'sdk'].join('-')
|
||||
const unavailableRepository = `${unavailableOwner}/${unavailableName}`
|
||||
const encodedRepository = unavailableRepository.replaceAll('-', '%2D').replace('/', '%2F')
|
||||
const htmlEncodedRepository = unavailableRepository.replace('/', '/')
|
||||
const jsonEscapedRepository = unavailableRepository.replace('/', '\\/')
|
||||
const unicodeEscapedRepository = unavailableRepository.replace('/', String.raw`\u002f`)
|
||||
const source = [
|
||||
'https://github.com/deepseek-ai/deepseek-harness-sdk',
|
||||
`https://github.com/${internalOwner}/cordis`,
|
||||
`https://github.com/${internalRepository.toUpperCase()}/issues/1`,
|
||||
'https://github.com/deepseek-ai/deepseek-harness',
|
||||
`https://github.com/${unavailableRepository.toUpperCase()}/issues/1`,
|
||||
`https://github.com/${encodedRepository}/issues/2`,
|
||||
`https://github.com/${htmlEncodedRepository}/issues/3`,
|
||||
`"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`,
|
||||
`"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`,
|
||||
`${internalOwner.toUpperCase()}#6`,
|
||||
`https://github.com/${unavailableOwner}/cordis`,
|
||||
`https://github.com/example/${unavailableName}`,
|
||||
].join('\n')
|
||||
|
||||
expect(findInternalRepositoryReferences('subject.md', source)).toEqual([
|
||||
expect(findUnavailableRepositoryReferences('subject.md', source)).toEqual([
|
||||
{ file: 'subject.md', line: 2 },
|
||||
{ file: 'subject.md', line: 3 },
|
||||
{ file: 'subject.md', line: 4 },
|
||||
{ file: 'subject.md', line: 5 },
|
||||
{ file: 'subject.md', line: 6 },
|
||||
{ file: 'subject.md', line: 7 },
|
||||
{ file: 'subject.md', line: 8 },
|
||||
])
|
||||
})
|
||||
|
||||
it('allows only the exact audited trusted-publishing repository declarations', () => {
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const repositoryUrl = `git+https://github.com/${internalRepository}.git`
|
||||
const manifestLine = ` "url": "${repositoryUrl}",`
|
||||
const constraintLine = `const repositoryUrl = '${repositoryUrl}'`
|
||||
const allowedDeclarations = [
|
||||
['native/landlock-run/packages/entry/package.json', manifestLine],
|
||||
['native/landlock-run/packages/linux-arm64/package.json', manifestLine],
|
||||
['native/landlock-run/packages/linux-x64/package.json', manifestLine],
|
||||
['scripts/check-workspace-constraints.ts', constraintLine],
|
||||
] as const
|
||||
it('preserves frozen archived Agent Notes', () => {
|
||||
const unavailableRepository = ['deepseek-ai', 'deepseek-harness-sdk'].join('/')
|
||||
|
||||
for (const [file, source] of allowedDeclarations) {
|
||||
expect(findInternalRepositoryReferences(file, source)).toEqual([])
|
||||
}
|
||||
|
||||
const wrongFile = 'native/landlock-run/package.json'
|
||||
expect(findInternalRepositoryReferences(wrongFile, manifestLine)).toEqual([{ file: wrongFile, line: 1 }])
|
||||
|
||||
const manifestFile = 'native/landlock-run/packages/entry/package.json'
|
||||
const wrongField = ` "homepage": "${repositoryUrl}",`
|
||||
expect(findInternalRepositoryReferences(manifestFile, wrongField)).toEqual([{ file: manifestFile, line: 1 }])
|
||||
|
||||
const encodedLine = manifestLine.replace('github.com/', 'github.com\\/')
|
||||
expect(findInternalRepositoryReferences(manifestFile, encodedLine)).toEqual([{ file: manifestFile, line: 1 }])
|
||||
expect(findUnavailableRepositoryReferences(
|
||||
'.agents/notes/archived/process/historical-record.md',
|
||||
`https://github.com/${unavailableRepository}`,
|
||||
)).toEqual([])
|
||||
expect(findUnavailableRepositoryReferences(
|
||||
'.agents/notes/implemented/process/active-record.md',
|
||||
`https://github.com/${unavailableRepository}`,
|
||||
)).toEqual([{ file: '.agents/notes/implemented/process/active-record.md', line: 1 }])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Reject tracked files that expose the internal repository identity outside audited publishing declarations. */
|
||||
/** Reject tracked files that reference an unavailable legacy repository. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs'
|
||||
@@ -6,22 +6,13 @@ import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const internalIssueShorthand = `${internalOwner}#`
|
||||
const trustedPublishingRepositoryUrl = `git+https://github.com/${internalRepository}.git`
|
||||
|
||||
/** Exact declarations that intentionally expose the source repository for trusted publishing. */
|
||||
const allowedInternalRepositoryLineByFile: Readonly<Record<string, string>> = {
|
||||
'native/landlock-run/packages/entry/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'native/landlock-run/packages/linux-arm64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'native/landlock-run/packages/linux-x64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'scripts/check-workspace-constraints.ts': `const repositoryUrl = '${trustedPublishingRepositoryUrl}'`,
|
||||
}
|
||||
const unavailableOwner = ['deepseek', 'ai'].join('-')
|
||||
const unavailableRepositoryName = ['deepseek', 'harness', 'sdk'].join('-')
|
||||
const unavailableRepository = `${unavailableOwner}/${unavailableRepositoryName}`
|
||||
const archivedAgentNotePrefix = '.agents/notes/archived/'
|
||||
|
||||
const namedReferenceCharacters: Readonly<Record<string, string>> = {
|
||||
hyphen: '-',
|
||||
num: '#',
|
||||
sol: '/',
|
||||
}
|
||||
|
||||
@@ -40,8 +31,8 @@ function canonicalReferenceText(source: string): string {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** One tracked reference to the internal repository. */
|
||||
export interface InternalRepositoryReference {
|
||||
/** One tracked reference to the unavailable repository. */
|
||||
export interface UnavailableRepositoryReference {
|
||||
/** Repository-relative file path. */
|
||||
file: string
|
||||
/** One-based source line. */
|
||||
@@ -49,20 +40,18 @@ export interface InternalRepositoryReference {
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate unaudited internal-repository references in one text file.
|
||||
* Locate unavailable-repository references in one active text file.
|
||||
* @param file - Repository-relative path used in diagnostics.
|
||||
* @param source - Text to inspect.
|
||||
* @returns every matching source line.
|
||||
* @returns every matching source line, excluding frozen archived Agent Notes.
|
||||
*/
|
||||
export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] {
|
||||
const references: InternalRepositoryReference[] = []
|
||||
export function findUnavailableRepositoryReferences(file: string, source: string): UnavailableRepositoryReference[] {
|
||||
if (file.startsWith(archivedAgentNotePrefix)) return []
|
||||
|
||||
const references: UnavailableRepositoryReference[] = []
|
||||
for (const [index, line] of source.split('\n').entries()) {
|
||||
const canonicalLine = canonicalReferenceText(line)
|
||||
const isAllowedPublishingDeclaration = line.trim() === allowedInternalRepositoryLineByFile[file]
|
||||
if (!isAllowedPublishingDeclaration
|
||||
&& (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand))) {
|
||||
references.push({ file, line: index + 1 })
|
||||
}
|
||||
if (canonicalLine.includes(unavailableRepository)) references.push({ file, line: index + 1 })
|
||||
}
|
||||
return references
|
||||
}
|
||||
@@ -73,8 +62,8 @@ function trackedFiles(repoRoot: string): string[] {
|
||||
.filter(file => file !== '')
|
||||
}
|
||||
|
||||
function scanRepository(repoRoot: string): InternalRepositoryReference[] {
|
||||
const references: InternalRepositoryReference[] = []
|
||||
function scanRepository(repoRoot: string): UnavailableRepositoryReference[] {
|
||||
const references: UnavailableRepositoryReference[] = []
|
||||
for (const file of trackedFiles(repoRoot)) {
|
||||
const path = resolve(repoRoot, file)
|
||||
if (!existsSync(path)) continue
|
||||
@@ -82,7 +71,7 @@ function scanRepository(repoRoot: string): InternalRepositoryReference[] {
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) continue
|
||||
const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8')
|
||||
if (source.includes('\0')) continue
|
||||
references.push(...findInternalRepositoryReferences(file, source))
|
||||
references.push(...findUnavailableRepositoryReferences(file, source))
|
||||
}
|
||||
return references
|
||||
}
|
||||
@@ -92,9 +81,9 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re
|
||||
if (isMain) {
|
||||
const references = scanRepository(root)
|
||||
if (references.length === 0) {
|
||||
console.log('verify-public-repository-links: tracked files expose no unexpected internal repository identity.')
|
||||
console.log('verify-public-repository-links: tracked files reference no unavailable repository.')
|
||||
} else {
|
||||
console.error('verify-public-repository-links: unexpected internal repository references found:')
|
||||
console.error('verify-public-repository-links: unavailable repository references found:')
|
||||
for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user