dsh-model 0.0.0-stage → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +122 -2
  4. package/bin/dsh-model.js +8 -0
  5. package/dist/bridge/aggregate.js +91 -0
  6. package/dist/bridge/login.js +127 -0
  7. package/dist/bridge/runtime.js +136 -0
  8. package/dist/bridge/server.js +178 -0
  9. package/dist/bridge/workbuddy/_compat.js +52 -0
  10. package/dist/bridge/workbuddy/app-version.js +141 -0
  11. package/dist/bridge/workbuddy/auth.js +548 -0
  12. package/dist/bridge/workbuddy/catalog.js +154 -0
  13. package/dist/bridge/workbuddy/client-identity.js +234 -0
  14. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  15. package/dist/bridge/workbuddy/loopback.js +52 -0
  16. package/dist/bridge/workbuddy/probe.js +153 -0
  17. package/dist/bridge/workbuddy/status-paths.js +49 -0
  18. package/dist/bridge/workbuddy/upstream.js +1180 -0
  19. package/dist/bridge/workbuddy/variants.js +101 -0
  20. package/dist/cli.js +207 -0
  21. package/dist/commands/bridge.js +68 -0
  22. package/dist/commands/connect.js +67 -0
  23. package/dist/commands/engine.js +122 -0
  24. package/dist/commands/key.js +75 -0
  25. package/dist/commands/login.js +113 -0
  26. package/dist/commands/logs.js +19 -0
  27. package/dist/commands/opencode.js +77 -0
  28. package/dist/commands/remote.js +129 -0
  29. package/dist/commands/service.js +72 -0
  30. package/dist/commands/setup.js +216 -0
  31. package/dist/commands/sources.js +196 -0
  32. package/dist/commands/status.js +216 -0
  33. package/dist/commands/uninstall.js +97 -0
  34. package/dist/commands/workbuddy.js +117 -0
  35. package/dist/context.js +86 -0
  36. package/dist/daemon/client.js +27 -0
  37. package/dist/daemon/core.js +790 -0
  38. package/dist/daemon/service.js +33 -0
  39. package/dist/daemon/stats.js +126 -0
  40. package/dist/daemon/usage.js +213 -0
  41. package/dist/dsh/cli.js +72 -0
  42. package/dist/dsh/connect.js +257 -0
  43. package/dist/dsh/credentials.js +55 -0
  44. package/dist/dsh/locate.js +129 -0
  45. package/dist/dsh/patch.js +106 -0
  46. package/dist/dsh/pick.js +59 -0
  47. package/dist/engine/auth.js +40 -0
  48. package/dist/engine/client.js +104 -0
  49. package/dist/engine/compat.js +94 -0
  50. package/dist/engine/config.js +47 -0
  51. package/dist/engine/install.js +118 -0
  52. package/dist/engine/mgmt.js +99 -0
  53. package/dist/engine/sourcemap.js +48 -0
  54. package/dist/errors.js +14 -0
  55. package/dist/i18n.js +39 -0
  56. package/dist/integrations/dshplugin.js +42 -0
  57. package/dist/integrations/opencode.js +101 -0
  58. package/dist/integrations/workbuddy.js +114 -0
  59. package/dist/keys.js +54 -0
  60. package/dist/ops.js +178 -0
  61. package/dist/remote/public.js +109 -0
  62. package/dist/secrets.js +13 -0
  63. package/dist/service/index.js +46 -0
  64. package/dist/service/launchd.js +89 -0
  65. package/dist/service/systemd.js +83 -0
  66. package/dist/sources.js +36 -0
  67. package/dist/state.js +90 -0
  68. package/dist/upstreams.js +23 -0
  69. package/dist/util/diff.js +30 -0
  70. package/dist/util/exec.js +42 -0
  71. package/dist/util/fs.js +107 -0
  72. package/dist/util/output.js +50 -0
  73. package/dist/util/pkg.js +13 -0
  74. package/dist/util/port.js +16 -0
  75. package/dist/util/prompt.js +40 -0
  76. package/dist/util/proxy.js +73 -0
  77. package/dist/util/redact.js +7 -0
  78. package/engine-manifest.json +28 -0
  79. package/package.json +90 -4
  80. package/plugin/client.js +549 -0
  81. package/plugin/cordis.patch.yml +4 -0
  82. package/plugin/index.js +177 -0
@@ -0,0 +1,178 @@
1
+ // bridge/server.ts — dsh-model 的 WorkBuddy bridge:把 WorkBuddy 私有接口变成本机 OpenAI 兼容接口
2
+ //
3
+ // 只绑 127.0.0.1,要求内部密钥(引擎的 openai-compatibility 上游带着它来);Host / Origin 必须是回环,挡 DNS rebinding。
4
+ // 路由按产品分前缀:/cn/v1/...(WorkBuddy 国内版)、/ai/v1/...(WorkBuddy AI 国际版)。
5
+ // 协议细节(凭据解密、客户端身份、刷新、目录)来自移植的 ./workbuddy/*(MIT,见其 LICENSE)。
6
+ import { timingSafeEqual } from 'node:crypto';
7
+ import { createServer } from 'node:http';
8
+ import { Readable } from 'node:stream';
9
+ import { aggregateSse } from './aggregate.js';
10
+ import { hostIsLoopback, originIsLoopback } from './workbuddy/loopback.js';
11
+ import { extractDisplayErrorMessage, prepareChatBody } from './workbuddy/upstream.js';
12
+ const BODY_LIMIT = 64 * 1024 * 1024;
13
+ const KIND_STATUS = {
14
+ hard_credit: 402,
15
+ soft_rate: 429,
16
+ session_dead: 401,
17
+ not_found: 502,
18
+ server: 502,
19
+ client: 400,
20
+ };
21
+ function json(res, status, body) {
22
+ const payload = JSON.stringify(body);
23
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
24
+ res.end(payload);
25
+ }
26
+ function oaiError(res, status, code, message) {
27
+ json(res, status, { error: { message, type: code, code } });
28
+ }
29
+ function readBody(req) {
30
+ return new Promise((resolve, reject) => {
31
+ const chunks = [];
32
+ let size = 0;
33
+ req.on('data', (c) => {
34
+ size += c.length;
35
+ if (size > BODY_LIMIT) {
36
+ reject(new Error('request body too large'));
37
+ req.destroy();
38
+ return;
39
+ }
40
+ chunks.push(c);
41
+ });
42
+ req.on('end', () => resolve(Buffer.concat(chunks)));
43
+ req.on('error', reject);
44
+ });
45
+ }
46
+ export function createBridge(options) {
47
+ const expected = Buffer.from(options.secret);
48
+ // variants 可能在运行中增加(新登录了一个产品),每次请求现查
49
+ const log = options.log ?? (() => { });
50
+ const authorized = (req) => {
51
+ const m = /^Bearer\s+(.+)$/i.exec(String(req.headers.authorization ?? '').trim());
52
+ if (!m)
53
+ return false;
54
+ const got = Buffer.from(m[1]);
55
+ return got.length === expected.length && timingSafeEqual(got, expected);
56
+ };
57
+ const server = createServer((req, res) => {
58
+ void handle(req, res).catch((error) => {
59
+ log(`bridge: unhandled ${String(error)}`);
60
+ if (!res.headersSent)
61
+ oaiError(res, 500, 'internal', 'bridge internal error');
62
+ else
63
+ res.end();
64
+ });
65
+ });
66
+ async function handle(req, res) {
67
+ if (!hostIsLoopback(req.headers.host))
68
+ return oaiError(res, 403, 'host_not_allowed', 'Host must be loopback');
69
+ if (!originIsLoopback(req.headers.origin))
70
+ return oaiError(res, 403, 'origin_not_allowed', 'Origin must be loopback');
71
+ if (!authorized(req))
72
+ return oaiError(res, 401, 'unauthorized', 'missing or invalid bearer');
73
+ const fullUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
74
+ const url = fullUrl.pathname.replace(/\/+$/, '') || '/';
75
+ if (req.method === 'GET' && url === '/healthz')
76
+ return json(res, 200, { ok: true });
77
+ if (req.method === 'GET' && url === '/status') {
78
+ const out = {};
79
+ for (const v of options.variants) {
80
+ const st = await v.store.status().catch((e) => ({ signedIn: false, error: String(e) }));
81
+ out[v.key] = { label: v.label, models: v.catalog.current().length, auth: st };
82
+ }
83
+ return json(res, 200, out);
84
+ }
85
+ if (url.startsWith('/control/') && options.onControl) {
86
+ let body = undefined;
87
+ if (req.method !== 'GET' && req.method !== 'DELETE') {
88
+ const raw = (await readBody(req)).toString('utf8');
89
+ try {
90
+ body = raw ? JSON.parse(raw) : undefined;
91
+ }
92
+ catch {
93
+ return oaiError(res, 400, 'invalid_json', 'request body is not JSON');
94
+ }
95
+ }
96
+ const r = await options.onControl(req.method ?? 'GET', url.slice('/control'.length), body, fullUrl.searchParams);
97
+ return json(res, r.status, r.body);
98
+ }
99
+ if (req.method === 'POST' && url === '/refresh') {
100
+ if (!options.onRefresh)
101
+ return oaiError(res, 404, 'not_found', 'refresh not supported');
102
+ return json(res, 200, { ok: true, result: await options.onRefresh() });
103
+ }
104
+ const m = /^\/([a-z]+)\/v1\/(models|chat\/completions)$/.exec(url);
105
+ const variant = m ? options.variants.find((v) => v.key === m[1]) : undefined;
106
+ if (!m || !variant)
107
+ return oaiError(res, 404, 'not_found', `no such route: ${req.method} ${url}`);
108
+ if (m[2] === 'models' && req.method === 'GET') {
109
+ return json(res, 200, { object: 'list', data: variant.catalog.current().map((model) => ({ id: model.id, object: 'model', created: 0, owned_by: 'workbuddy' })) });
110
+ }
111
+ if (m[2] === 'chat/completions' && req.method === 'POST')
112
+ return chat(variant, req, res);
113
+ return oaiError(res, 405, 'method_not_allowed', `${req.method} ${url}`);
114
+ }
115
+ async function chat(variant, req, res) {
116
+ if (!String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json'))
117
+ return oaiError(res, 415, 'unsupported_media_type', 'Content-Type must be application/json');
118
+ let credential;
119
+ try {
120
+ credential = await variant.store.resolve();
121
+ }
122
+ catch (error) {
123
+ return oaiError(res, 401, 'not_signed_in', `${variant.label}: ${String(error.message ?? error)} — sign in again in the ${variant.label} app`);
124
+ }
125
+ const raw = (await readBody(req)).toString('utf8');
126
+ // OpenAI 规范:不写 stream 就是非流式。曾按 "!== false" 判断,没写 stream 的请求被当成流式,
127
+ // 把上游的 ": heartbeat" 原样透传给引擎,引擎既解析不了 JSON,也拿不到 token 用量(VPS 实测)
128
+ let wantsStream = false;
129
+ try {
130
+ wantsStream = JSON.parse(raw).stream === true;
131
+ }
132
+ catch {
133
+ return oaiError(res, 400, 'invalid_json', 'request body is not JSON');
134
+ }
135
+ const controller = new AbortController();
136
+ res.on('close', () => controller.abort());
137
+ const result = await variant.client.chatStream(credential, prepareChatBody(raw), controller.signal);
138
+ if (!result.ok) {
139
+ const detail = extractDisplayErrorMessage(result.message) ?? result.message.slice(0, 400);
140
+ return oaiError(res, KIND_STATUS[result.kind], result.kind, `${variant.label} upstream ${result.kind} (http ${result.status}): ${detail}`);
141
+ }
142
+ const body = Readable.fromWeb(result.response.body);
143
+ if (!wantsStream) {
144
+ let text = '';
145
+ for await (const chunk of body)
146
+ text += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
147
+ return json(res, 200, aggregateSse(text));
148
+ }
149
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
150
+ let sawDone = false;
151
+ body.on('data', (c) => {
152
+ if (c.includes('[DONE]'))
153
+ sawDone = true;
154
+ });
155
+ body.on('error', (error) => {
156
+ log(`bridge: ${variant.label} stream failed mid-flight: ${String(error)}`);
157
+ // 上游中途断开:补发 [DONE],让客户端正常收尾而不是挂住
158
+ if (!sawDone && res.writable)
159
+ res.end('data: [DONE]\n\n');
160
+ });
161
+ body.pipe(res);
162
+ }
163
+ const ready = new Promise((resolve, reject) => {
164
+ server.once('error', reject);
165
+ server.listen(options.port, options.host ?? '127.0.0.1', () => {
166
+ const addr = server.address();
167
+ resolve(typeof addr === 'object' && addr ? addr.port : options.port);
168
+ });
169
+ });
170
+ return {
171
+ server,
172
+ ready,
173
+ close: () => new Promise((resolve) => {
174
+ server.close(() => resolve());
175
+ server.closeAllConnections();
176
+ }),
177
+ };
178
+ }
@@ -0,0 +1,52 @@
1
+ // _compat.ts — 移植代码原本依赖 dsh 的 @deepseek-ai/dsh-atomic-write 与 @deepseek-ai/dsh-home-paths,这里给出 dsh-model 的等价实现。
2
+ // resolveDshHome() 在移植代码里只用来放自己的状态文件(凭据副本、版本缓存),所以指向 bridge 的状态目录,而不是 dsh 的 DSH_HOME。
3
+ import { open, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { atomicWrite, ensureDir } from '../../util/fs.js';
7
+ /** bridge 的状态目录:$DSH_MODEL_HOME/workbuddy(默认 ~/.dsh-model/workbuddy) */
8
+ export function resolveDshHome() {
9
+ const home = process.env.DSH_MODEL_HOME ? resolve(process.env.DSH_MODEL_HOME) : join(homedir(), '.dsh-model');
10
+ return join(home, 'workbuddy');
11
+ }
12
+ export async function writeFileAtomic(file, data, options = {}) {
13
+ await ensureDir(dirname(file), { mode: options.dirMode ?? 0o700 });
14
+ await atomicWrite(file, data, { mode: options.mode ?? 0o600 });
15
+ }
16
+ /** 跨进程文件锁(bridge 与 CLI 可能同时刷新令牌):<file>.lock 用 O_EXCL 抢占,过期 30s 视为遗留 */
17
+ export async function withFileLock(file, fn) {
18
+ const lock = `${file}.lock`;
19
+ await ensureDir(dirname(lock), { mode: 0o700 });
20
+ const deadline = Date.now() + 15_000;
21
+ for (;;) {
22
+ try {
23
+ const handle = await open(lock, 'wx', 0o600);
24
+ await handle.writeFile(String(Date.now()));
25
+ await handle.close();
26
+ break;
27
+ }
28
+ catch (error) {
29
+ if (error.code !== 'EEXIST')
30
+ throw error;
31
+ try {
32
+ const { stat } = await import('node:fs/promises');
33
+ if (Date.now() - (await stat(lock)).mtimeMs > 30_000) {
34
+ await rm(lock, { force: true });
35
+ continue;
36
+ }
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ if (Date.now() > deadline)
42
+ throw new Error(`timed out waiting for lock ${lock}`);
43
+ await new Promise((r) => setTimeout(r, 100));
44
+ }
45
+ }
46
+ try {
47
+ return await fn();
48
+ }
49
+ finally {
50
+ await rm(lock, { force: true });
51
+ }
52
+ }
@@ -0,0 +1,141 @@
1
+ // Ported from dsh-workbuddy-connect v0.7.1 (commit 390582e04da4e8e8bf746d71997440e4b716ec47),
2
+ // https://github.com/corrinehu/dsh-workbuddy-connect — MIT License, Copyright (c) 2026 Corrine Hu.
3
+ // See LICENSE.dsh-workbuddy-connect in this directory. Changes: imports adapted to dsh-model (./_compat.js).
4
+ /**
5
+ * The international desktop app's version, used as the `/v3/config` UA.
6
+ *
7
+ * The App-shaped catalog is served only to a User-Agent carrying the product
8
+ * name (see `docs/workbuddy-ai-international-research-2026-09-11.md` §2.7).
9
+ * That document's conclusion recommended the space form `WorkBuddy AI/<v>`;
10
+ * re-measured on 2026-09-11 the *space* form is rejected (HTTP 400, code
11
+ * 12403) while the terse `WorkBuddyAI/<v>` form — with or without the space
12
+ * removed — returns the 21-model App document. The UA is therefore built from
13
+ * the form verified in code, not from the earlier prose.
14
+ *
15
+ * The version feeds the international catalog UA and the chat identity's
16
+ * `X-IDE-Version`: a missing App, an unreadable plist, or a bad cached value
17
+ * degrades to the last saved value and finally to a compiled-in constant, and
18
+ * never blocks credential use or the provider.
19
+ *
20
+ * @module dsh-workbuddy-connect/app-version
21
+ */
22
+ import { readFile } from 'node:fs/promises';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { writeFileAtomic } from './_compat.js';
26
+ import { resolveDshHome } from './_compat.js';
27
+ /**
28
+ * Last-resort UA version.
29
+ *
30
+ * The gateway ignores the version number when splitting the UA (research §2.7.2
31
+ * item 2: `CLI/1.0.0`, `CLI/99.0.0` and the real version all return the same
32
+ * document), so this constant is a shape requirement rather than a currency
33
+ * claim. It is *not* used to infer anything about model capabilities.
34
+ */
35
+ export const FALLBACK_APP_VERSION = '5.5.2';
36
+ /** Basename of the saved version under `$DSH_HOME`. */
37
+ export const WORKBUDDY_APP_VERSION_FILENAME = '.workbuddy-ai-version.json';
38
+ /**
39
+ * Whether a string is safe to interpolate into an HTTP header.
40
+ *
41
+ * Strict on purpose: the value reaches a header, so anything that could split
42
+ * the request (CR, LF, spaces beyond the separator) or inject a second UA
43
+ * token must never pass. The App's own version is always `N.N.N` or `N.N.N.N`.
44
+ */
45
+ export function validAppVersion(value) {
46
+ return typeof value === 'string' && /^\d{1,6}(?:\.\d{1,6}){1,3}$/u.test(value);
47
+ }
48
+ /** macOS App-bundle roots: system-wide first, then the user's own install. */
49
+ function macAppRoots() {
50
+ return ['/Applications', join(homedir(), 'Applications')];
51
+ }
52
+ /**
53
+ * Read `CFBundleShortVersionString` out of an `Info.plist`.
54
+ *
55
+ * Parsed as XML rather than grepped, because the plist contains several
56
+ * `<string>` values and a regex would be one unrelated key away from
57
+ * returning the wrong one. A binary plist has no `<dict>` in its bytes and is
58
+ * reported as unreadable (the saved value then applies) rather than guessed at.
59
+ */
60
+ export async function readBundleVersion(plistPath) {
61
+ let text;
62
+ try {
63
+ text = await readFile(plistPath, 'utf8');
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ // `<key>CFBundleShortVersionString</key>` followed by the next `<string>`.
69
+ const match = /<key>\s*CFBundleShortVersionString\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(text);
70
+ const version = match?.[1]?.trim();
71
+ return validAppVersion(version) ? version : undefined;
72
+ }
73
+ /**
74
+ * The installed international App's version, or `undefined` when it is not
75
+ * installed (or not readable).
76
+ *
77
+ * Windows and Linux have no verified bundle-metadata location yet, so this
78
+ * returns `undefined` there and the saved/fallback value is used instead of
79
+ * guessing a path — the same discipline the credential discovery follows.
80
+ */
81
+ export async function installedAppVersion() {
82
+ if (process.platform !== 'darwin')
83
+ return undefined;
84
+ for (const root of macAppRoots()) {
85
+ const bundle = join(root, 'WorkBuddy AI.app');
86
+ const version = await readBundleVersion(join(bundle, 'Contents', 'Info.plist'));
87
+ if (version !== undefined)
88
+ return { version, bundle };
89
+ }
90
+ return undefined;
91
+ }
92
+ /** Saved-version file path under the Harness home. */
93
+ export function appVersionPath() {
94
+ return join(resolveDshHome(), WORKBUDDY_APP_VERSION_FILENAME);
95
+ }
96
+ /**
97
+ * Resolve the UA version: installed App first, then the last saved value, then
98
+ * the compiled-in fallback.
99
+ *
100
+ * A value read from the App is written back immediately, so an uninstalled App
101
+ * or an unreadable plist later still has the last real version to fall back
102
+ * on. The write is best-effort: failing to cache a version must never fail the
103
+ * catalog request that asked for it.
104
+ */
105
+ export async function resolveAppVersion(options = {}) {
106
+ const path = options.path ?? appVersionPath();
107
+ const installed = await (options.installed ?? installedAppVersion)();
108
+ if (installed !== undefined && validAppVersion(installed.version)) {
109
+ try {
110
+ await writeFileAtomic(path, `${JSON.stringify({ version: installed.version, bundle: installed.bundle, observedAt: Date.now() }, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
111
+ }
112
+ catch {
113
+ // A read-only home is not a reason to serve no catalog.
114
+ }
115
+ return { version: installed.version, source: 'installed', bundle: installed.bundle };
116
+ }
117
+ try {
118
+ const saved = JSON.parse(await readFile(path, 'utf8'));
119
+ if (typeof saved === 'object' && saved !== null) {
120
+ const version = saved['version'];
121
+ if (validAppVersion(version))
122
+ return { version, source: 'saved' };
123
+ }
124
+ }
125
+ catch {
126
+ // Absent or malformed cache: fall through to the compiled-in default.
127
+ }
128
+ return { version: FALLBACK_APP_VERSION, source: 'fallback' };
129
+ }
130
+ /**
131
+ * Build the App-shaped User-Agent for catalog requests.
132
+ *
133
+ * `WorkBuddyAI/<version>` with no space is the form measured to reach the App
134
+ * document; the space form is rejected with 400/12403. Throws on an invalid
135
+ * version rather than sending a malformed header.
136
+ */
137
+ export function appUserAgent(version) {
138
+ if (!validAppVersion(version))
139
+ throw new Error(`invalid WorkBuddy AI version for User-Agent: ${JSON.stringify(version)}`);
140
+ return `WorkBuddyAI/${version}`;
141
+ }