跳到主要内容

dsh-qa-surface

已验证

@yadsh/dsh-qa-surface · v0.15.7 · MIT · Web 界面

A focused end-user QA surface backed by native DeepSeek Harness sessions

安装

dsh plugin add @yadsh/dsh-qa-surface

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

发布到 npm 但没有公开仓库。安装前请检查包内容。

标签

说明文档

@yadsh/dsh-qa-surface

A focused, responsive QA/chat page for DeepSeek Harness at /qa. It replaces the presentation, not the harness: prompts, streaming, tools, skills, MCP, memory, persistence, permissions and telemetry continue through the native DSH Session and Agent Loop.

What it does

  • contributes one root-scoped entry to the additive shell.overlay slot;
  • stays invisible outside the configured route;
  • creates or restores one real DSH Session;
  • renders only user text, assistant-visible text and safe status messages;
  • supports streaming, Stop, optional New chat, safe Markdown, copy actions and a responsive first-party-style conversation layout;
  • queues a question asked while the assistant is still answering: the strip above the composer lists what waits for the next turn, and each row can be edited, sent into the running turn, or dropped. The queue is the Host session's own state, so nothing is stored here and a message never reads as both queued and sent;
  • renders assistant Markdown with this plugin's own GFM grammar — headings through ######, nested and task lists, tables, quotes, images, autolinks, reference links, TeX math ($…$, $$…$$, ```math fences, through a bundled self-contained KaTeX) and footnotes ([^label] with the trailing section) — inside an HTML-free renderer, and relays the host theme's own typography and syntax colors (--dsw-font-markdown-*, --dsw-alias-markdown-*, --shiki-token-*) so a fenced block here reads like one in DSH's own transcript, in either theme and at any font-size preference. A fence carries the language banner, a copy button and a built-in highlighter for the languages answers use; an unknown or absent language stays plain monospace;
  • optionally shows a minimal per-browser chat-history sidebar (ui.showSessionList) whose switching re-runs policy attestation;
  • blocks unsupported approvals/questions instead of auto-approving them, and can park them for the operator to answer (interaction.approvals: interactive, interaction.questions: interactive) instead of refusing or stalling on a card the QA view cannot show; a parked question takes the composer's place until it is answered or its turn ends;
  • pins locked sessions to the configured read-only or isolated workspace-write policy plus approval=never before Send is enabled;
  • applies a Host-side tool allow-list plus a monotonic execution guard;
  • separates account authorization (admin/user) from the QA agent's active subrole, with server-owned Common, per-role Tools and Skills, immutable session snapshots, and a dedicated administration page at /qa/admin;
  • attaches its own QA tool catalog per agent only after the activation skill loads (tools.dynamicActivation), keeping every QA schema out of the initial request and restoring the catalog on resume from the session's own journal;
  • ships a destructive-but-fenced file_delete tool in that catalog: it removes one regular file strictly inside the chat's workspace and refuses directories, missing paths and anything that leaves the root — symlink escapes included — with an explicit, path-safe reason; a call from the chat's own agent answers ask, so the interactive approval card parks it for the operator, a delegated call is refused outright, and nothing is ever deleted without a person's answer;
  • optionally gates the surface behind email + password accounts (accounts.enabled) with server-side session ownership, a first-login migration of the browser's existing chats, a qa-accounts management CLI (list/add/set-password/set-role/disable/revoke), and a coarse honest boundary: accounts identify QA users, they do not fence the harness root;
  • gives each account a Настройки dialog — profile, starter messages, notification channels, integration tokens, general, and personal skills: ordinary Agent Skills stored as SKILL.md in the account's own directory (accounts.skills), edited with a catalog, an invocation-flag form, a Markdown body, a tool picker over the deployment's registry, and a preview of the exact file a save writes. Skills reach the model through a provider this plugin registers instead of the filesystem one, so no account can see another's, and allowed-tools is stored as declared but never grants anything the session does not already allow;
  • carries its own light/dark/system palette control in the header: the Host's Appearance row lives in the settings this surface suppresses, so /qa would otherwise open in whichever theme the application happened to boot with (see Theme);
  • optionally redirects non-loopback hostnames from the harness root into the QA route (entry.redirectNonLoopback), keeping the operator's localhost harness UI untouched;
  • ships an operator settings card on the Host's Plugins page — this package's row, opened under its configuration section — that edits the qa-surface namespace in place (route, branding, session, interface, lockdown, accounts, sources, attachments, embedding) and reports the configuration the running Host resolved;
  • no longer owns the document pipeline: document_create, document_to_markdown, document_from_url, document_convert and document_inspect come from @yadsh/dsh-documents, which a QA chat reaches exactly as before — same tool names, same allow-list entry, same artifact layout. What moved with it is the configuration: the pipeline is configured in that plugin's own documents namespace and card (QA_DOCUMENTS_* environment variables became DSH_DOCUMENTS_*), and a leftover documents: section under qa-surface is ignored with a documents.moved warning in the Host log;
  • uses the existing same-origin DSH connection and trust boundary.

It does not add another HTTP server, provider proxy, permissive CORS rule, or custom session persistence.

Install

Install the package where the DSH Host can resolve it, then compose its Loader row. The included cordis.patch.yml is a minimal example:

- insert:
    - id: dsh-qa-surface
      name: "@yadsh/dsh-qa-surface"
      config:
        enabled: true
        route:
          path: /qa
          matchChildren: true

Some DSH patch operations replace a row's complete config instead of deeply merging it. When editing an existing row, provide every value that deployment needs.

Tested DSH releases serve unknown frontend paths as 404 rather than falling back to index.html. The Host half therefore claims only the configured QA navigation path and redirects it through the canonical / document with a short-lived query marker. The browser restores the requested /qa URL with history.replaceState before mounting the overlay. No second server, duplicate HTML document, permissive route, or unauthenticated config endpoint is added.

Configure

Configuration is registered under the Host settings namespace qa-surface. Composition values form the base layer; normal DSH user settings can override them when the deployment provides writable settings.

The browser half carries a settings card for that namespace: the Host's Plugins page → this package's row → its configuration section. It writes the user layer of qa-surface — so every change is revertible through the card's own reset — and shows the configuration the running Host resolved next to it. Combinations the Host refuses are either written together in one mutation (a provider with its model, per-user workspaces with the workspace-write sandbox) or disabled with the reason stated. The row's chrome is the page's: it draws the surface, the heading and the expand control, and this bundle contributes the body — the heading falls back to the package name and the row's one-liner to this manifest's description, which is the sentence the card answers its summary view with. The Plugins page is not the settings directory, so the card keeps answering from a browser on another machine: there the namespace reads unavailable — the DSH gateway pins it to loopback — and the card says so rather than vanishing, while a namespace that serves values but refuses writes keeps its body and disables the write controls. Either way the same configuration stays readable anywhere through qaSurface/describe on the QA page itself.

The row on the Plugins page is the only browser route that writes these values. Where a deployment keeps that page from the operator, the config: block below is what sets them: it is the composition layer the card's user layer overrides, and the values it names are the ones the card would have changed.

config:
  enabled: true
  route:
    path: /qa
    matchChildren: true
  branding:
    title: Внутренний помощник
    subtitle: Отвечает на вопросы о внутренней платформе
    welcomeMessage: Чем могу помочь?
    placeholder: Задайте вопрос…
    logoUrl: null
  session:
    # Pin every chat to a directory (optional, one of):
    # cwd: "D:/qa-docs"        # direct absolute directory pin
    # workspaceId: "<uuid>"    # or a registered DSH workspace
    policy: browser-persistent
    storageKey: dsh-qa-surface.session
    workspaceId: company-knowledge
    fixedSessionId: null
    agentPreset: qa-assistant
    provider: null
    model: null
    reasoningEffort: null
    # How many questions the stand answers at once; 0 sets no ceiling.
    maxActiveRequests: 0
  ui:
    showHeader: true
    showReset: false
    showStop: true
    showTimestamps: false
    showToolActivity: false
    showReasoning: false
    renderMarkdown: true
    minContentWidth: 650
    showSessionList: false
  accounts:
    enabled: false
    allowRegistration: true
    sessionTtlDays: 30
    # Login/registration attempts accepted per rolling minute, store-wide
    # (optional). The limit backs the password checks; raise it only when a
    # shared kiosk genuinely needs the headroom.
    maxAuthAttemptsPerMinute: 30
    showOtherUsersChats: false
    perUserWorkspace: false
    profile:
      enabled: true
      inject: true
      identities: [] # e.g. [{ key: jira, label: Jira }]
      instructionsMaxLength: 2000
    # Per-account starter buttons above an empty composer (optional).
    starters:
      enabled: true
  suggestedQuestions:
    - Как запросить доступ?
    - Где лежит инструкция?
  # Phrases the running indicator cycles through (optional). An empty list
  # restores the built-in ones.
  thinkingPhrases:
    - Уточняю по регламенту…
    - Сверяюсь с инструкцией…
  # What a visitor may attach (optional). Files are staged on the Host and
  # read from the stored copy, so keep `read` in lockdown.toolPolicy.allow.
  attachments:
    textFiles: true
    pastedTextLines: 200
    maxFileBytes: 10485760
    maxPending: 8
    extensions: [md, txt, log, json, yaml, csv, sql]
  interaction:
    # blocked: refuse a composed gate's `ask` with the QA reason.
    # interactive: park it over the composer for the operator to answer.
    approvals: blocked
    # unsupported: refuse ask_user_question with an actionable reason.
    # interactive (or enabled): park the request over the composer as an
    # answerable form, which owns the composer until it is answered or the
    # turn ends.
    questions: unsupported
  # Root-page entry behavior (optional; see "Serving the QA rig over the LAN").
  entry:
    # Inject the root → /qa redirect for non-loopback hostnames.
    redirectNonLoopback: true
    # Let the /qa route run the one-time ?token= host-cookie exchange itself,
    # so transparent entry works without the deploy proxy.
    cookieBootstrap: true
  # Turn-completion notices for the chats of this browser's own history.
  notifications:
    # Off: a finished turn stays silent on both channels.
    enabled: true
    # Off: the page never hands a finished turn to the operating system,
    # whatever a reader chose there.
    allowOs: true
  lockdown:
    enabled: true
    enforceFixedAgentPreset: true
    enforceFixedWorkspace: true
    enforceFixedModel: true
    sandboxMode: read-only
    approvalPolicy: never
    permissionPreset: qa-read-only
    allowPermissionChanges: false
    allowSlashCommands: false
    allowSettingsMutation: false
    allowSessionReset: false
    allowSessionRename: false
    allowSessionDelete: false
    allowArbitrarySessionOpen: false
    toolPolicy:
      mode: allow-list
      allow: []
  sources:
    enabled: true
    collect:
      parentAgent: true
      subagents: true
      persistTurnEvent: true
    display:
      sidebar: true
      footer: true
      groupByKind: true
      showDiscovered: false
      showOriginBadges: false
      maxInitiallyVisiblePerGroup: 8
    webSearch:
      promoteSearchResultsWithoutFetch: true
      maxPromotedPerSearch: 5
    dedupe:
      normalizeUrls: true
      stripTrackingParams: true
      mergeFileRanges: true
    filePreview:
      enabled: true
      markdownRenderedByDefault: true
      allowRawToggle: true
      maxBytes: 2000000
      maxMarkdownRenderBytes: 1000000
    subagents:
      inheritSources: true
      enableReportToolFallback: true
      markIncompleteOpaqueRuns: true
      validateReportedSources: true
    legacy:
      parseAssistantSourcesBlock: false
  # QA tool delivery (optional). The plugin's own tool catalog stays out of the
  # model request until the activation skill has actually been loaded.
  tools:
    dynamicActivation: true
    activationSkill: qa-surface
    activationMode: all
    # Presets whose sessions may unlock the catalog. Empty leaves the gate open:
    # only do that when this Host serves one agent composition.
    activationPresets: []
    # Where the documentation readers look. Empty reads `docs/` inside each
    # chat's own workspace; name the corpus when it is published once, outside
    # every per-user workspace (see "Dynamic QA tools" below).
    docsRoot: ""

workspaceId is recommended for a deterministic assistant. Without it, DSH uses the Host's normal default working directory. Put the system prompt, tools, skills, knowledge connections and permission policy in agentPreset, not in this UI plugin.

Dynamic QA tools

A QA deployment tends to grow a large toolset, and attaching all of it at boot puts every schema into every request — including the first small talk of a chat that will never use them. tools.dynamicActivation (the default) inverts that: the plugin's catalog is registered into the agent's own scope only after the model successfully loads tools.activationSkill, and qa_tools_selfcheck reports the resulting state.

As of catalog version 3 the shipped catalog carries four tools: qa_tools_selfcheck, the activation diagnostic; docs_search and docs_read, the documentation surface; and file_delete, the one destructive capability — it deletes a single regular file strictly inside the calling chat's workspace and refuses directories, missing paths and anything that escapes the root, symlink escapes included, with an explicit reason that never echoes a host path. Every file_delete call from the chat's own agent is answered ask by an inner gate that sits inside the approval flow, so on a deployment with interaction.approvals: interactive the interactive approval card parks the call for the operator, and on blocked the call is refused outright: nothing is deleted without a person. A delegated child of that chat is refused by the same inner gate without a card — a child cannot be confirmed by anyone, and a card parked over the parent's composer holds the parent turn for an answer that cannot arrive. Like every catalog tool it is admitted as a dynamic name at execution time — it needs no lockdown.toolPolicy entry — and a role-managed deployment grants it through the same Tools baskets as any other tool.

docs_search and docs_read are the documentation surface. Documentation is published into the docs/ directory of the chat's workspace and the tools read exactly that tree: docs_search matches a phrase inside single lines and reports every hit with its path and line number, tagged with the module and version parsed out of the layout docs/<module>/<version>/…; both names are also accepted as filters, so a chat that was told "3.8" stops sweeping every edition, and path narrows a search to one subtree. docs_read opens one file at a bounded window of lines. Both stay inside the tree — a path outside it, a docs/ that is missing or is not a real directory, a directory passed to a read, a binary file and a link that leaves the tree are refused with an explicit reason that never echoes a host path — and both bound what they return: limit and a byte budget on the reported hits, a line budget on a read, and a truncated answer says so instead of quietly dropping matches. The tool descriptions carry the routing rule the catalog exists for: documentation is looked up here, not in memory and not by sweeping guessed paths.

The trigger is the authoritative result of the built-in skill tool, not the model's attempt, not a keyword in the transcript, and not a coincidentally matching skill description. A failed or refused load activates nothing, loading an unrelated skill activates nothing, and loading the same skill again is a no-op. If one tool fails to register, the whole attempt is unwound and the agent stays inactive — a half-attached surface would leave the model with a tool it cannot rely on. Registrations live exactly as long as the agent that owns them, so disposal and plugin unload leave no scoped tool behind.

A resumed chat is restored from its own journal: the successful skill load is already recorded there as a standard tool/call/tool/result pair, so the current catalog is re-attached before the first model step. The plugin appends no session event of its own — an unknown event type without an ignorable marker makes the whole log unreadable to a harness that does not mount this plugin.

Set tools.activationPresets to the preset your QA surface pins (qa-research in the deploy kit) in any Host that composes more than one agent type. With an empty list the catalog is reachable by any agent that loads a skill of the same name. tools.dynamicActivation: false restores the always-on behaviour and attaches the catalog to every managed agent at creation — useful for a deployment that would rather debug the tool surface than the trigger.

docs_search and docs_read read the docs/ directory of the calling chat's own workspace by default. A deployment that publishes the corpus once — for example /workspace/docs next to a per-user workspace layout — sets tools.docsRoot to that absolute path, because with accounts.perUserWorkspace: true the chat's workspace is a per-account directory that holds no documentation: the readers would answer "there is no documentation tree here" in every chat, while the glob/grep/read tools reached the corpus by absolute path all along. The root is held to the same rules either way: an absolute path, a real directory, no symlinks, and every path is canonicalized and refused if it leaves the tree.

These tools are not lockdown.toolPolicy.allow entries, and cannot be: that list is validated against the mounted catalog at attestation time, and a tool that only appears later would fail the check. The QA execution guard authorizes exactly the names the activation manager reports for the calling agent, so a dynamically attached tool gets the same scrutiny as an allow-listed one. Tool visibility is not an authorization boundary — a QA tool that writes must still enforce its own permissions.

Writable per-user research space

Set accounts.perUserWorkspace: true only together with accounts, a registered session.workspaceId, lockdown.enforceFixedWorkspace: true, and a workspace-write + never permission preset:

session:
  workspaceId: "<registered-workspace-uuid>"
accounts:
  enabled: true
  perUserWorkspace: true
lockdown:
  enabled: true
  enforceFixedWorkspace: true
  sandboxMode: workspace-write
  approvalPolicy: never
  permissionPreset: qa-workspace-write
  toolPolicy:
    mode: allow-list
    allow:
      [
        read,
        read_image,
        glob,
        grep,
        write,
        edit,
        web_search,
        web_fetch,
        dsh_git_context,
        dsh_git_history,
        dsh_git_show,
        dsh_git_blame,
      ]
  sharedReadOnlyRoots:
    - E:/qa-assistant/workspaces/docs
    - E:/qa-assistant/workspaces/code

The Host resolves that Workspace record's path and creates <workspace>/.qa-users/<account UUID> with private Unix directory mode. It passes the child as session cwd but deliberately does not register or attach it as another DSH Workspace. Chats therefore remain ordinary entries in the global DSH session list rather than creating one Workspace row per account.

That choice has one visible consequence: in the host's workspace browser these chats sit under Ungrouped. DSH grants Workspace membership only to a session whose stored cwd IS the Workspace path - Workspace.attachSession compares the two after realpath, and the browser derives its groups from workspace.sessionIds alone - so a per-account child directory can never be a member, and no later action can make it one: the contract has no attach or membership request for an existing session, and dragging a session never crosses groups. Registering a Workspace per account directory would group them, at the cost of putting every visitor's scratch root into the operator's global workspace registry; this plugin does not do that.

Chats left outside every workspace for a repairable reason - created while the deployment pinned session.cwd, or through workspaceId with the same directory spelled differently (E:/base against E:\base) - can be adopted while DSH is stopped:

qa-attach-sessions                    # dry run against $DSH_HOME
qa-attach-sessions --write            # adopt; registry backed up first

The command adopts only sessions whose canonical cwd IS a registered Workspace path, prepends them newest first, and refuses anything below a Workspace path, because the Host drops those from membership again on the next read. Per-user chats are therefore never touched.

The boundary combines DSH workspace-write with a Host tool guard for both read and write paths, canonicalizes existing ancestors to reject symlink escapes, propagates the root to subagent sessions, rejects shell/process/LSP escape hatches, permits filesystem reads in explicitly configured shared read-only roots, and leaves repository selection to the separately configured read-only Git plugin. Writes remain confined to the account directory. The guard limits one model-controlled write to 10 MiB, and limits an account directory to 256 MiB. web_fetch plus write is the intended bounded research-download path; there is no unrestricted URL-to-disk or shell downloader. Account directories are persistent scratch space and are not deleted automatically.

For the layout above, configure @yadsh/dsh-git-readonly separately with repositoryRoots: ["E:/qa-assistant/workspaces/code"]. That plugin is the single repository-selection authority and exposes no mutating Git operation.

Model override is opt-in: provider and model must be set together. Slash commands are rejected as plain QA input. Reasoning and tool details remain hidden by default. Enabling ui.showReasoning and ui.showToolActivity adds a turn-scoped work disclosure: it stays open while the assistant is working, then collapses to Worked for ... before the final answer. Tool capability is still controlled exclusively by lockdown.toolPolicy.allow; the display flags do not grant tools.

User profile

Clicking the account name in the sidebar footer opens the signed-in user's profile: full name, one handle per external system the deployment declares, and free-form instructions about how they want answers. The Host hands both to the QA agent as a note in the conversation, so "покажи мои задачи" resolves to a tracker lookup with the right login instead of a question. Because a QA preset can declare its persona the complete system prompt (the shipped qa-research one does, which discards every plugin prompt section), the note travels as injected context on the conversation instead of as prompt text. It is written once per profile, and subagents of the chat get their own copy. Values are self-declared and the note says so: the agent names the identifier it searched by and asks when the results contradict the request. The feature needs no switch beyond accounts, though accounts.profile.enabled and inject exist for deployments that want the form without the note, or neither:

accounts:
  enabled: true
  profile:
    identities:
      - key: jira
        label: Jira
      - key: gitlab
        label: GitLab
    instructionsMaxLength: 2000

qa-accounts profile <email> fills the same fields from an operator shell (--identity jira=i.ivanov, --instructions-file, --clear-identity), which is how a fresh deployment gets everyone's handles in place before users log in. See Configuration for the limits and the prompt's exact wording.

The wording of that note — and of the source-provenance, delegation-naming and attached-documents and source-priority notes — is editable without touching the source: the «Заметки модели» section of the settings card (the notes config block) mutes each note and rewords its text, keeping the generated parts ({identity}, {instructions}, {reportTool}) as placeholders. The attached-documents note is the one that sends a .docx or .pdf from the chat to the document pipeline instead of the plain file reader, which refuses those formats as binary. The source-priority note is the one that says an answer belongs to the documentation or the expert before it belongs to memory.

Theme

Three cubes in the header, next to the role control: light, dark, and follow the system. The surface needs its own because the Host keeps its Appearance row inside the settings — and the QA overlay is precisely what suppresses the native shell, while the kiosk never mounts it. Without these cubes the stand opens in whichever palette the application booted with and a visitor has nowhere to answer that.

The choice belongs to the browser, not to the deployment. It is stored under this stand's own localStorage namespace (<storageKey>:v1:<route>:theme) and never written to the Host user-settings document: a stand is shared by everyone who reaches it, and one person's eyes are not a configuration. A browser that never touched the control stores nothing and writes nothing at all — the stand keeps the palette the application booted it in, so an untouched deployment looks exactly as it did before. The control then reports the palette on screen instead of claiming a preference nobody picked.

What a click writes is the Host's own palette contract — color-scheme on the root and the dark-palette attribute on the body, the two fields the Host's theme presenter owns — which is why everything follows it: the cards, the transcript, the drawers and the dialogs are painted from --dsw-alias-* tokens, and those tokens are declared under exactly those selectors. The font-size axis and a theme's own token overrides stay the Host's. system resolves through prefers-color-scheme and keeps listening, so a laptop going dark at dusk takes the chat with it. Once a preference is chosen the control marks the preference, never the resolved palette, so «Системная тема» stays pressed while the operating system decides which theme that is.

The document is borrowed, not owned: what a click writes is put back when the surface stops being what the visitor sees — the route changing inside the application, or the overlay unmounting. Off its own route this control is not on screen to undo itself, and a harness left in a QA stand's palette would stay in it for the rest of the visit. The palette put back is the one the document wore when the choice was applied, retaken on every repaint, so the Host's own answer wins again the moment the visitor leaves.

One consequence worth knowing: the control is part of the header, so a deployment that hides the header (ui.showHeader: false) hides the only way to reach it.

Starter messages

The same Настройки dialog carries a «Быстрые сообщения» section where the signed-in user defines their own starter buttons: each entry is a label (what the button reads) and a prompt (what pressing it sends), so a button can say «Мои задачи» while sending a full tracker request. The deployment's suggestedQuestions stay visible next to the user's own buttons unless the user hides them with the section's toggle. The list is stored on the account (next to the profile, in the accounts file), replaces wholesale on save, and is pure UI preference — none of it reaches the agent prompt. accounts.starters.enabled (default true) turns the section off for deployments that want the buttons to stay operator-defined.

Notification channels

A turn that ends in a chat the reader is not looking at says so, and the «Уведомления» section of the same Настройки dialog is where they decide how far that reaches: the line inside the page, and the notice a hidden or backgrounded tab can hand to the operating system. Both belong to the account rather than to the browser, so the choice follows the person to another machine; where a stand has no accounts, the desktop choice stays in the browser that made it. The permission itself is the browser's and is asked for once, from a click. What the stand refuses with notifications.enabled or notifications.allowOs stays refused, and the section says which of the two closed a channel instead of offering a switch that cannot take effect. Like the starter messages, none of this reaches the agent prompt, and a notice never carries the answer itself — only the chat's title and the fact that its turn ended.

Integration tokens

The same Настройки dialog carries an «Интеграционные токены» section, so the account that runs an integration issues and revokes its own credential instead of asking the operator to run the CLI. It lists the tokens that account owns — label, scopes, creation and expiry dates, use count and last use — mints a new one, shows its secret exactly once (with a copy button and the warning that it is never recoverable, because only the digest is stored), and revokes one with a second confirming click. A revoked or expired token stays in the list as a record, with its revoke button gone.

Creating is offered only where the credential has somewhere to go: with integration.enabled: false the section explains that the API is off and hides the form, while listing and revoking keep working — a credential that already exists has to remain revocable. The section is part of the accounts domain, so it appears wherever accounts.enabled is on, and the same self-service rules apply: the token always belongs to the signed-in account, and one account never sees another's tokens. The CLI (qa-accounts token create|list|revoke) remains the operator's path, including issuing a token for somebody else.

Session policies:

  • browser-persistent restores the id stored in this browser, replacing stale ids with a new session;
  • new-on-load creates a session each time the QA surface mounts;
  • fixed requires fixedSessionId, never creates a replacement, and is meant only for controlled single-user deployments.

New chat is disabled by default. To expose it, set lockdown.allowSessionReset: true plus either ui.showReset: true (header button) or ui.showSessionList: true (sidebar button). Pressing it opens a draft composer and creates nothing: the DSH Session is materialized lazily by the first prompt, so the chat list stays quiet until a message is actually sent, and the old session stays intact for operator inspection. The sidebar orders chats by the host's last update, so merely opening a chat never moves it.

A stand whose model runs locally can also refuse to take questions it cannot answer at the same time: session.maxActiveRequests names that ceiling, and 0 — the default — names none. The count is the Host's, read from the agents that are answering right now, so a question that arrived through the HTTP API occupies a place too and a browser never has to guess about chats that are not its own. Before a send the browser asks that read; a question with no place left is never sent — no session is materialized, nothing enters the transcript, the composer keeps the text, and the visitor reads how many requests the stand is already working. That count includes this visitor's own turns, which is why the dialog names occupied places rather than people ahead: a refused question is not queued behind a number the stand cannot attribute. It is a ceiling rather than a lock: the prompt itself rides the native session RPC, so two questions pressed in the same instant can still overshoot by one. What the ceiling buys is the steady state, and an unreadable count sends the question anyway. It bounds questions, not every other send. A message typed while a chat is answering joins that chat's own queue and costs no second place, so it is admitted without the read; a human command from the palette goes to the Host's command runtime and is never held back, because that surface is how a visitor inspects or repairs a saturated stand and this plugin cannot tell which commands wake the model. A command that does wake it enters the same Host count, so the next question waits behind it like behind any other turn.

Regeneration: the last committed answer offers a retry action. The session log is append-only, so "regenerate" sends a hidden instruction as an ordinary prompt and the answer arrives as a follow-up turn; the projection hides that instruction and the consecutive turns read as variants of one question, navigable with a < 2/2 > switcher (newest shown by default).

Sources are structured Host-owned provenance, independent of ui.showToolActivity. Successful reads/fetches, bounded web-search evidence, Jira/Confluence/knowledge results, and inherited subagent sources are normalized and deduplicated into one turn bundle. That exact bundle feeds the answer footer and the right rail's sources tab and is persisted in the plugin-owned $DSH_HOME/qa-sources.json, so reload does not rerun tools and no custom event enters the Harness session journal. Search-only discovery stays hidden by default.

A source enters a turn bundle through two channels, and the definition is their union. The extractor registry (src/provenance/extractors.ts) is the channel that reads durable tool results: a completed call becomes a source only when one of its seven extractors matches it — a read naming a workspace path, a fetch naming a URL, a search returning its source list, a file search shaped as matches, or a Jira, Confluence or knowledge record. The collector then keeps the evidence half of what the registry returned, so a file that only appeared in a search result joins the list once the answer actually reads it. The transcript projection rebuilds bundles from this registry alone. The Host store is the second channel: it also takes a report the answering agent files itself through the qa_report_sources tool (src/provenance/host-store.ts, normalized in src/provenance/reported.ts), and the Host's bundle wins the turn it shares with the projection (src/client/session-sources.ts), so a source the agent reported by hand can sit in a list no extractor produced. sources.subagents.validateReportedSources decides how strict that report is — by default every entry still has to name a path or a URL, switched off it keeps its own kind, title and snippet with no address at all.

Neither channel collects injected memory or a bridged recall tool (an mcp__openviking__* read returning a viking:// address as text): no extractor matches it, the deployment note treats recalled memory as background from earlier sessions rather than the source an answer is looked up in, and a virtual address has no file preview to open. Nor does a document the model only names in its prose — no channel parses the answer's wording. A turn that answered from memory or from what the conversation already carried therefore ends with no source at all, unless the agent reports something for it itself, which is the model's word rather than the collector's read of a tool result.

That is why the header control never disables on an empty list while the answer footer stays unrendered: the panel owns the explanation, and a dead button hid the only surface that gives it. Its empty state names the classes that do appear and says plainly that an empty list is not a collection failure — and it is drawn only once the collection has settled, because an unsettled one already says which origins it is still waiting for, and a chat that has asked nothing yet has no answer to diagnose. Gating stays with the deployment — sources.enabled and sources.display.sidebar decide whether the control exists at all.

Legacy sessions written by earlier releases can be repaired while DSH is stopped. Preview changes first, then apply them with an automatic backup:

qa-repair-sessions
qa-repair-sessions --write

The repair only marks legacy safety-gate/* and qa/sources records as ignorable; it does not delete them. Each changed session file is backed up as *.pre-plugin-event-repair.bak before atomic replacement.

Local file cards open a source-scoped, read-only preview after Host-side real path validation against the roots the QA read policy opens: the chat's own directory, the configured shared read-only directories, and the attachment store. Markdown opens rendered by default with an HTML-free renderer and offers Rendered / Raw; raw mode jumps to recorded line ranges. The endpoint cannot browse or write files and refuses paths that are not evidence in the canonical bundle.

Observable local subagents are inherited recursively. The internal qa_report_sources tool covers opaque delegated providers and is admitted as a provenance-only capability even when it is not listed among ordinary QA tools. A provider that neither exposes events nor reports sources marks the turn provenance incomplete. A note in the conversation tells the model not to append a manual Sources/Источники bibliography.

Images: the composer accepts PNG/JPEG/WebP/GIF via drag & drop onto the composer, paste, and the picker button, several at once (soft client caps: 8 images, 15 MB each). Images ride the prompt as base64 uploads the Host promotes to durable attachments, so they survive reloads; sent images render as clickable thumbnails on the message. Whether the model can see them depends on the deployment's model (vision).

Attachments: the same picker, drop zone and paste path also take text files (md, txt, log, and the other extensions in attachments.extensions), and pasted text longer than attachments.pastedTextLines (default 200) becomes an attachment named after its line count instead of filling the input field. A file is staged on the Host through the browser upload service and the prompt cites the returned receipt, so the durable copy survives reloads; the transcript shows it as an extension badge, its name and its size. Unlike an image, a file reaches the model as the path of that stored copy rather than as content, so lockdown.toolPolicy.allow has to keep read for an attachment to be usable. attachments.textFiles: false restricts the composer to images again, while maxFileBytes and maxPending cap one file and the combined number of images plus files per message.

The right rail is the chat's side panel, mirroring the Harness right Sidebar's pattern: a tab strip is the panel's whole top edge, and the strip's close control collapses the column. The sources tab carries the grouped list and preview the sources drawer used to render — a message footnote opens it pinned to that answer's subset, and «Все источники» returns to the whole chat. The «Файлы» tab (header button with a live count) lists every attachment this chat sent, grouped per message newest first, with the same file handles the transcript shows and image thumbnails resolved from the session's asset repository; each group jumps back to its message. Below 600px the rail goes full-bleed. The agents drawer keeps its own header drawer for now.

Slash commands and skills

Off by default. Typing / in the composer normally gets the same refusal it always did («Команды со слешем недоступны в режиме помощника»), and nothing about that changes for a deployment that upgrades.

Turn it on with the master switch, then say exactly what it admits:

lockdown:
  allowSlashCommands: true

slashCommands:
  skills:
    mode: allow-list # deny-all | allow-list | all
    allow:
      - generate-tkp
      - generate-tz
      - gap-analysis
  commands:
    mode: deny-all # deny-all | allow-list | all
    allow: []
  palette:
    enabled: true
    fuzzySearch: true
    maxVisible: 12
    showDescriptions: true
    showKindBadge: true

The switch and the policy are two separate decisions: turning slashes on opens the palette, and the palette offers only what the two lists name. A deployment that enables the switch and declares nothing gets the legacy behaviour — every user-invocable skill of the chat, no commands at all — and the Host says so once in its log (slash.legacy-defaults) rather than silently behaving as if it had been configured.

The two kinds behave differently, and the difference is the point:

Skill                                    Human command
  /generate-tkp Сделай ТКП                 /compact
  → ordinary model turn                    → the Host runs it
  → the native skill consumer injects      → the model never sees it
    the skill's instructions               → command/run + command/done land
  → QA reads no SKILL.md and injects       in the session log, projected as a
    nothing itself                           control row, not an answer bubble

A skill with user-invocable: true and disable-model-invocation: true shows up in the palette and runs; the model still cannot see it. A skill with user-invocable: false never appears. /name typed inside an ordinary sentence still works the way it does everywhere else in the Harness — QA does not rewrite that path — but when the deployment withholds that particular skill, the composer says so before the turn runs instead of letting the user believe it took effect.

Keyboard and touch: / opens the palette above the composer and it closes as soon as a space is typed (you are writing arguments by then). ↑/↓ move, Tab and Enter insert the invocation without running it — the second Enter sends — and Escape closes. Clicking or tapping a row inserts it and leaves the caret in the field. A skill and a command that share a name are two separate rows, and a hand-typed /plan when both exist asks which one you meant rather than guessing.

Admission is the Host's, not the browser's. The catalog arrives already filtered by the policy and by the chat's role, and /compact typed by hand is re-checked against the same policy before the native runtime is allowed near it. The slash interface changes nothing about tools, the sandbox, the permission preset or approvals: a skill invoked by hand carries exactly the permissions it carries when the model loads it.

One part of that user list is not the role's to name. A skill the account keeps in its own skills root belongs to one person, and a role is shared by many, so the account's own user-invocable skills join its /name list directly: palette, typed gesture and the enforcement guard read the same field and cannot disagree, while the model's catalog stays the role's. The role ceiling still bounds the tools such a skill activates with, and an administrator's withdrawal of the name outright outranks the personal layer.

The settings card carries the same policy under «Слеш-действия», with the allow lists as plain name lists — the config stores names, never ids.

Panel extensions

QA Surface can host optional feature panels without importing those features. The shell owns the launcher, side-by-side/fullscreen layout, resizing and generic close chrome; an extension owns its feature state and controls. No panel is shown, and no launcher space is reserved, when no extension is installed.

An external client plugin uses two registrations. Metadata and navigation go through the qaSurfacePanels service; the React body is registered separately in the keyed qa.surface.panel slot under the same implementation id:

import type { Context } from "@deepseek-ai/cordis";
import type { PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
import { QA_SURFACE_PANEL_SLOT } from "@yadsh/dsh-qa-surface/client/panels";

const id = "@example/dsh-qa-browser";

function BrowserPanel(props: PropsRuntime<typeof QA_SURFACE_PANEL_SLOT>) {
  // panelId, panelKind, sessionId, visible, presentation, params,
  // actions and a registration-lifetime AbortSignal arrive in props.
  return null;
}

export const inject = ["slots", "qaSurfacePanels"];

export function apply(ctx: Context) {
  ctx.effect(() =>
    ctx.qaSurfacePanels.register({
      id,
      kind: "browser",
      title: () => "Browser",
      icon: "browser",
      order: 100,
      keepMounted: true,
    }),
  );

  ctx.slots.inject(QA_SURFACE_PANEL_SLOT, () =>
    ctx.slots.register({ name: QA_SURFACE_PANEL_SLOT, key: id }, BrowserPanel),
  );

  ctx.qaSurfacePanels.open("browser", {
    reason: "extension",
    focus: false,
  });
}

The extension should declare both slots and qaSurfacePanels in its client inject list instead of polling for load order. keepMounted: true preserves local/continuous UI state while another panel is active; hidden retained bodies are removed from keyboard navigation. Presentation state is browser-local and never enters the DSH Session log. Browser processes, tools, security policy and artifacts remain the responsibility of the Browser plugin, not QA Surface.

User Settings extensions

An optional client plugin can add a first-class page to the signed-in user's existing Настройки dialog through the published @yadsh/dsh-qa-surface/client/settings contract. Register the page with the qaUserSettingsSections service; QA Surface owns the navigation and supplies only the current account token to the selected page. The extension must still authorize every Host call server-side and must not persist credentials in the browser.

import type { Context } from "@deepseek-ai/cordis";

export const inject = ["qaUserSettingsSections"];

export function apply(ctx: Context) {
  ctx.effect(() =>
    ctx.qaUserSettingsSections.register({
      id: "integrations",
      title: "Интеграции",
      order: 40,
      component: IntegrationsPage,
    }),
  );
}

The signed-in account outside the dialog

A card mounted outside the QA dialog — in the plugin's own row on the Host's Plugins panel (plugins.row.config) — has no panel props to read the account from, so the same contract also publishes the session as the qaUserSession client service: checking, anonymous or authed with the bearer credential the principal-scoped QA remotes authorize with. It follows the same account controller the pages use, so a card and the dialog never disagree. Subscribe to it with useSyncExternalStore and render account-bound controls only for authed — without an account every call would be refused, and the credential is transport authentication only: never persist it, log it, or put it in a URL, a tool argument or any model-visible value.

import type { Context } from "@deepseek-ai/cordis";

export const inject = ["qaUserSession", "slots"];

export function apply(ctx: Context) {
  ctx.effect(() =>
    ctx.slots.inject("plugins.row.config", () =>
      ctx.slots.register(
        // `<package name>#<row id>`, the row id being the settings namespace.
        { name: "plugins.row.config", key: "@yadsh/dsh-my-plugin#my-namespace" },
        MyCard,
      ),
    ),
  );
}

Subagents: the deployment may opt the delegation family (subagent, subagent_fork, send_message, list_agents, interrupt_agent) into the lockdown allow-list; the preset must mount them. Launches then render as first-class work items (description, background flag, durable child id), settlement notices appear as status rows, an "Агенты" header drawer lists the chat's subagents with live status, and any subagent opens as a read-only live transcript (composer disabled, one click back to the chat) - viewing never attests or writes.

ui.showSessionList: true renders a minimal chat-history sidebar beside the conversation. By default it lists only the current user's chats: the client keeps a per-browser id index under <storageKey>:v1:<route>:chats in localStorage (capped at 50, most recently used first) and intersects it with the Host session list, so users sharing the deployment never see each other's chats. An admin can explicitly enable accounts.showOtherUsersChats: true to add chats owned by other QA accounts, grouped by owner. Switching re-runs the full policy attestation, and that attestation materializes the chat's agent when the Host does not hold one: DSH builds an agent on demand, so a chat restored after a Host restart still opens (its composition is resumed from what the session recorded) instead of failing as unavailable. If that recorded agent preset, workspace or model belongs to an older deployment configuration, the history still opens in an explicit compatibility read-only mode. Send, stop, approvals and questions stay disabled so the historical session cannot bypass the current workspace or permission boundary; use New chat to continue under the current configuration. A chat the Host no longer lists is pruned from the index, and so is a delegated subagent session: the rows, the chat counter in the account settings and the first-login claim batch all read the same lineage marks the host list carries, so a subagent's transcript can never be reopened, counted or migrated as a chat. Each row carries a two-click delete control that removes the chat from this browser's index; deleting the chat that is currently open continues in a fresh attested session. Host-side sessions are not deleted — DSH 0.1.x exposes no session-deletion seam. The sidebar hides below 600px viewports.

The sidebar footer shows the deployed plugin version. Clicking it opens a changelog dialog with a curated per-version summary (features and fixes); Escape or a backdrop click closes it.

On /qa, the plugin shadows DSH's stock welcome-notice onboarding entry and renders a route-owned Russian testing disclosure. Keeping the visible dialog in the QA overlay prevents DSH's blank-session onboarding lifecycle from dismissing it when a question is submitted. It explains the DeepSeek Harness preview foundation, QA review of questions and answers, the work-related scope, and local in-contour model processing. Explicit acknowledgement is stored as a versioned browser-local flag; changing the disclosure version shows it again. Other DSH routes retain the stock onboarding entry.

The default locked mode requires a deployment permission preset named qa-read-only. Per-user writable mode uses a separate preset such as qa-workspace-write. interaction.approvals: interactive does not change this requirement: it parks an ask returned by a composed tool gate in the QA view, while the permission preset must keep approval: never as the independent fail-closed backstop. Setting the preset itself to approval: ask fails deployment preflight with (reason: permission-preset) before a Host session is created. Extend the existing @deepseek-ai/dsh-permission-presets row without changing its process-wide default:

- id: permission
  name: "@deepseek-ai/dsh-permission-presets"
  config:
    presets:
      read-only:
        sandbox: read-only
        approval: ask
      workspace-write:
        sandbox: workspace-write
        approval: ask
      danger-full-access:
        sandbox: danger-full-access
        approval: never
      qa-read-only:
        sandbox: read-only
        approval: never
        name: QA Read Only
        description: No filesystem mutations and no permission escalation.
      qa-workspace-write:
        sandbox: workspace-write
        approval: never
        name: QA User Workspace
        description: Writes only inside the attested per-user workspace.

The shipped tool allow-list is empty. Add only reviewed tool names from the actual deployment. A name that is not registered fails closed. The Host restriction retains exact allow-listed tools from the agent preset's ancestor scope, and the additional execution guard also denies session-scoped tools and run_code unless their exact names are allowed. Host-installed integration plugins may add their own narrowly scoped tool names through the QA Surface service; those executors must independently resolve the owner-attested root principal and fail closed for unowned or child sessions.

Subroles and capability policies

With accounts.enabled: true, every QA session has exactly one active agent subrole. The Host resolves its effective capabilities as the union of the minimal system-required set, Common capabilities, and that one subrole; it never unions all roles assigned to the user. The chosen role and effective snapshot are stored with session ownership. Existing conversations never gain new capabilities after an administrator edits a role, while a capability that disappears from the running registry is revoked immediately.

Administrators manage subroles, Common Tools/Skills, user assignments and the audit trail at /qa/admin. Capability choices come from the live tool and skill registries; configured-but-missing entries remain visible and are not deleted. Admin authorization affects only the management API and never grants agent capabilities. Preview as role creates an ordinary session using the selected role's real server-enforced policy and shows the preview banner, which carries the way out. The preview belongs to the navigation that opened it: it ends when that entry is left, and an ordinary account never holds it, so a chat started later in the same tab runs under the account's own default profile. A user page reports each assigned profile's effective capabilities the way a session resolves them: the deployment's pinned tools plus Common and the role, the skill-grantable ceiling separately, and declared audiences among the skills.

The configuration lives in $DSH_HOME/qa-capability-policies.db (a pre-0.8.0 qa-capability-policies.json beside it is imported on first use); user assignments and session snapshots stay with the existing account store. Tools are filtered with an agent-scoped restriction and a pre-execution gate. Skills use an agent-scoped skill consumer that publishes only the allow-listed catalog and rejects direct out-of-policy loads with SKILL_NOT_AVAILABLE. The browser selector is hidden for a single assigned role. Switching roles after a meaningful turn requires confirmation and creates a new conversation.

Tools that arrive with a skill, and tools taken away

A role's tools are split into three classes. always tools are visible from the first model step. skillGrantable tools are a ceiling, not a grant: they stay out of the model's tool list until an activated skill requires them, which keeps a large catalog such as browser automation out of every step of every conversation. An older flat tools: [] list is read as always, so an upgraded deployment never hands out more than it did before.

deny withdraws a tool. The deployment's pinned toolPolicy.allow reaches every profile, so a pinned name cannot simply be unchecked in a role; a denial beats it, beats the Common layer and beats every skill, and it narrows the ceiling below. Because the ceiling is a property of the conversation rather than of one agent, the agents a chat delegates to — subagents and the named domain experts — are held to it as well: they may reach anything the role can reach, including tools a skill would grant, and nothing beyond it. Withdrawing dsh_git_* from a role therefore withdraws it from that role's experts too.

Skills declare their own audience

A skill describes itself inside the ordinary metadata block of its SKILL.md, so the file stays a valid Agent Skill outside a QA deployment and no upstream schema is forked:

---
name: browser-research
description: Research websites through browser automation

metadata:
  qa-surface:
    version: 1
    audience:
      type: subroles # or: type: common
      include: [analyst, presales]
    tools:
      requires: [browser_open, browser_click]
      grant:
        lifecycle: session # the only lifecycle in v1
        requireAll: true
---

The audience decides visibility; the role ceiling decides what a skill may ever receive. A load intersects the two, so a skill cannot widen its own access. A skill that declares nothing stays Unassigned until an administrator assigns it, which is safer than treating every new skill as Common.

Loading the instructions and widening the toolset is one operation. A strict skill (requireAll: true) refuses to activate when one of its tools is unavailable, and a best-effort skill activates with a visible warning that names what it did not receive. The model's skill call and a typed /skill-name activate the same grant; a /name naming a skill outside the subrole is withdrawn before the step is assembled. Grants last for the current agent, and several loaded skills union their tools — activating a second skill never revokes what the first one still holds.

Administrators edit assignments as an overlay, never by rewriting SKILL.md: a role can be added to or withdrawn from a declared audience, a skill can be forced on for every role or disabled outright, and the Skills page shows the declared audience next to the effective one with a Healthy/Degraded/Blocked state. Every activation is recorded on the session record with the requested, granted and denied tools, so a later review can see what a conversation actually gained.

Administrative console

/qa/admin is the review and administration surface. It is part of the QA page itself, not a separate application, and it is open to admin and reviewer accounts. Reviewer sees conversations, the review queue, feedback, analytics and the experts' memory as it was recorded; only an administrator sees users, capability policies and audit, and corrects what an expert remembered.

The console covers the quality loop end to end:

  • Overview — conversation, rating and review counters, the items that need attention, and the newest signals.
  • Users — authorization role, enabled/disabled status, assigned QA subroles and the default one, plus per-account activity. Disabling an account is the operation to reach for: historical conversations, feedback and reviews stay attributed to their author, and the last enabled administrator cannot be demoted, disabled or removed.
  • Conversations — every conversation the deployment knows, filterable by user, subrole, date range, rating and review state, with a viewer that reads the stored transcript: messages in recorded order, tool calls with their arguments, results and errors, per-message feedback, and the capability snapshot frozen when the session started. A link can point at one message (/qa/admin/conversations/<id>/<seq>). An administrator can also delete the conversation from its own page: that removes the chat itself — its stored log, the sessions delegated from it, its ownership record, its ratings, reviews, queue entries and collected sources — unlike the sidebar's "delete chat", which hides a row in one browser. The Host refuses while the Harness still holds the session open or when the deployment stores sessions somewhere directories cannot express, and audits the act.
  • Review queue — what needs attention, derived from unanswered negative feedback, explicitly queued conversations and failed tool calls. A reviewer classifies issues across answer, context, tools, skills and access, sets a severity, writes notes and names the remediation target.
  • Feedback — every rating against the exact answer it judged, with the optional reason and comment the user gave.
  • Analytics — rating coverage, positive share overall and per subrole, issue distribution and a daily trend. These are user-satisfaction signals; the console never presents them as accuracy.
  • Expert memory — what each domain expert recorded for itself, which the plugin feeds back into every later answer of that domain. Searchable by key, text and tag; a record's text and tags can be corrected in place, and one record, a ticked selection or a whole namespace can be deleted. A reviewer reads it — noticing a remembered inaccuracy is a review finding — and only an administrator writes it. Wiping a namespace confirms the count the page showed, and every write is audited with the line as it was before. The section is empty on a stand that composes no domain experts.
  • Audit — one timeline of authorization changes, account status, subrole assignments, policy edits and review verdicts, each with its before/after image.

Authorization is a permission table, not an isAdmin flag, and every administrative entry point names the permission it needs; the Host re-checks it on the call it serves, so a hidden control is convenience rather than the boundary. Review material is the most sensitive data the package handles, so tool arguments and results are bounded previews with credential shapes masked, behind a redactor a deployment can replace.

Integration API (HTTP)

Another application — a ticket-system bridge, a bot, a script — can ask the same assistant questions over HTTP, with its own credential instead of a browser session, and read its own conversations back. The endpoints are off by default.

integration:
  enabled: true # requires accounts.enabled: true
  basePath: /qa/api # POST {basePath}/ask, GET {basePath}/session, GET {basePath}/health
  tokenTtlDays: 90
  requestTimeoutMs: 90000 # the answer budget; a slow model wants a larger one, see docs/INTEGRATION-API.md §2.5
  maxConcurrent: 4
  requestsPerMinute: 60
  maxAnswerCharacters: 4096 # the answer the ticket comment can hold

The API needs accounts: a caller is an account, and the credential it presents is that account's integration token. Switching it on without accounts.enabled: true is refused at configuration time rather than served without authentication.

Issuing a token

# the secret is printed exactly once and is never recoverable
qa-accounts token create [email protected] --label "ticket bridge" --scopes ask --days 90
qa-accounts token list [email protected]     # ids, scopes, expiry, last use
qa-accounts token revoke [email protected] <token-id>

An integration token is a separate credential from the browser token:

  • it survives a password change, because a service that is already integrated must not be logged out by a person editing their own profile;
  • it stores only a SHA-256 digest of its secret, so a copied database is not a copied credential;
  • it carries scopes (ask, sessions:read), an independent expiry and its own revocation;
  • it stops with the account: disabling the account refuses it, and qa-accounts revoke <email> revokes it together with every browser session.

Use one token per integration and revoke it when the integration is retired. Treat the secret like a password: it is a bearer credential with no second factor.

Asking a question

curl -sS https://dsh.example.local/qa/api/ask \
  -H "Authorization: Bearer qsat.<id>.<secret>" \
  -H "Content-Type: application/json" \
  -d '{
        "message": "TEST получения задач для ИИ Агента\n\nКомпоненты: MDC",
        "version": "3.8",
        "session_id": null,
        "context": {
          "ticket_key": "PROJ-123",
          "reporter": "[email protected]",
          "reporter_name": "Демо-пользователь"
        }
      }'
{
  "chat_id": "session-1f0c…",
  "answer": "**Ответ**\n\nТекст ответа в Markdown…",
  "sources": ["Документация_v3.8.pdf#стр.12"],
  "confidence": "medium",
  "escalate": false,
  "reason": ""
}

Send chat_id back as session_id to continue the same conversation. The chat belongs to the token's account: a token can only continue chats its own account owns, and a chat an integration opened keeps its own workspace, subrole and capability snapshot like any other QA chat.

multipart/form-data is accepted with the same fields (message, version, `s