zcode-workbuddy-connect 0.0.0-stage → 1.0.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.
@@ -0,0 +1,111 @@
1
+ # ADR 0004 — Read a 5.6 sign-in by opening its at-rest envelopes
2
+
3
+ Status: accepted (2026-09-25)
4
+
5
+ ## Context
6
+
7
+ Since WorkBuddy 5.6 the desktop app no longer writes its sign-in as plain JSON.
8
+ `auth.accessToken` and `auth.refreshToken` arrive as `{$wbEncrypted:1, envelope}`
9
+ wrappers (`buildPolicy: "fields"`, on by default), each sealed with AES-256-GCM
10
+ under a per-machine at-rest key. The plugin's credential path had been "read the
11
+ file, parse the JSON, take the token", so on a current install it read two
12
+ objects where it expected two strings — and the desktop file is the identity
13
+ authority for the region, so a file the plugin cannot read is a signed-out state
14
+ it cannot even explain.
15
+
16
+ Nothing in the plugin's own process can open those envelopes. The at-rest key is
17
+ delivered by the app's Electron-modified runtime through a private binding,
18
+ `process._linkedBinding("electron_browser_workbuddy_storage").loggerGet()`, which
19
+ returns `{version:1, atRestSecretKey}` — and that binding does not exist in
20
+ ZCode's stock Node, where this plugin runs. The format and its failure modes were
21
+ established from the app bundle and from upstream's issue reports (#39/#40 in
22
+ `corrinehu/dsh-workbuddy-connect`); the authenticated-context AAD is a
23
+ transcription of the app's own `buildAuthenticatedContextAad`.
24
+
25
+ ## Decision
26
+
27
+ **Classify the document before parsing it.** `classifyDesktopAuthDocument`
28
+ returns `absent`, `plaintext`, `encrypted` or `unrecognized`. A plaintext
29
+ document keeps going through the parser it always did, so the ordinary path is
30
+ unchanged. An encrypted document is *opened*, never skipped. An unrecognized one
31
+ fails loudly and names the path, because the desktop file outranks the
32
+ plugin-owned copy wherever it exists: quietly preferring the copy would let a
33
+ stale account sign the user in. An empty file is the one case that falls through
34
+ to the next candidate — an empty file cannot be authoritative about anything.
35
+
36
+ **Open envelopes by running the app's own binary once as Node.** The helper is
37
+ spawned as `<Electron> -e` with the one-line binding read and
38
+ `ELECTRON_RUN_AS_NODE=1`, and only the payload reaches stdout. The plugin never
39
+ copies the app's runtime, never patches it, and never starts it as a GUI.
40
+
41
+ **Derive the protector key with `sha256(atRestSecretKey, utf8)`**, cache it in
42
+ memory only, single-flight, and key that cache by the derived key's own id
43
+ (`sha256` of the key, first 16 hex characters). An envelope naming a different id
44
+ re-resolves the payload once: an app update or a fresh sign-in reseals the file,
45
+ and a stale key must never be used against a fresh envelope.
46
+
47
+ **Transcribe the AAD instead of guessing at it.** The `WB-AAD\0` prefix, the
48
+ `WBEV1` framing under `sym-v1`, the suite number, the length-prefixed key id and
49
+ the trailing context bytes are all constants of the app's own builder. Credential
50
+ fields are always suite 1 under the field framing; the neighbouring framings
51
+ (`WBEF1`/`WBER1`/`WBES1`) belong to other document kinds and are deliberately
52
+ left unimplemented — opening a credential is not a place to guess at a format.
53
+
54
+ **Auto-discover one product, on the platforms where it was verified.** The CN
55
+ app gets a platform default on Windows and macOS, and on macOS a Spotlight search
56
+ by bundle id that *proves* identity — a candidate declaring a different bundle id
57
+ is excluded, several Spotlight rows for one bundle collapse via realpath, a row
58
+ that is provably gone is skipped, and anything that could not be checked is
59
+ reported as incomplete rather than as "the app is missing". More than one
60
+ survivor is an error, not a coin toss. Everything else — Linux, and the
61
+ international app off Windows — gets `WORKBUDDY_ELECTRON_BIN` only, and a missing
62
+ binary there is reported as "not configured", never as "searched and failed".
63
+
64
+ This is a **deliberate deviation from upstream**, which refuses static
65
+ candidates. The refusal fits macOS, where a real discovery mechanism exists; it
66
+ does not transfer to Windows, which has none, and where the only alternative to a
67
+ static default is asking the user for an env var to run their own installed app.
68
+ The two Windows defaults are the app's own install directories and were verified
69
+ live.
70
+
71
+ **Never let a secret out, and never let one into a log.** The payload, the key
72
+ and the tokens are used in memory and written nowhere new. Error text carries
73
+ sizes, key ids and exit codes only — deliberately not helper stdout or stderr,
74
+ which can hold paths or crash dumps.
75
+
76
+ ## Consequences
77
+
78
+ **Gained.** A current WorkBuddy install works with no configuration on Windows
79
+ and macOS (CN). When it cannot work, the diagnosis is specific: a signed-out
80
+ state carries a `reasonCode` (`encrypted-credential-unreadable`,
81
+ `electron-binary-not-found`, `electron-binary-ambiguous`,
82
+ `electron-binary-unavailable`, `electron-discovery-incomplete`,
83
+ `electron-path-invalid`), and `doctor` prints the file's format, the binary the
84
+ unlock would use, and whether that binary exists — all without spawning or
85
+ decrypting anything for the report itself.
86
+
87
+ **Cost.** The plugin executes a foreign binary. It is the app's own, from a path
88
+ that names exactly one product, and only ever with `ELECTRON_RUN_AS_NODE=1`, so
89
+ no GUI or app code path runs; but the capability is real, and it is bounded by
90
+ refusing every location that has not been verified rather than by adding
91
+ fallbacks.
92
+
93
+ **Cost.** The desktop file can now be a hard failure where it used to be
94
+ invisible: a sealed file this plugin cannot open reports "fix or remove the
95
+ file" instead of silently falling back. That is intended — the alternative is
96
+ signing the user in as whoever the plugin-owned copy last belonged to.
97
+
98
+ ## Alternatives rejected
99
+
100
+ - **Read the token fields as opaque objects** (what a naive port does) — the
101
+ install is healthy and the plugin says signed-out. Useless.
102
+ - **Reimplement the binding in ZCode's Node** — the key material is produced by
103
+ the app's modified runtime; there is nothing to reimplement outside it.
104
+ - **Read a DPAPI/Keychain secret directly** — the at-rest key is not held by the
105
+ OS credential store; it is delivered through the app's binding.
106
+ - **Search the filesystem for any WorkBuddy install** — executing an
107
+ unidentified binary is not something a credential reader should do on a guess.
108
+ Upstream's refusal is kept everywhere the layout has not been verified live.
109
+ - **Copy the decrypted tokens into the plugin-owned store and be done** — that
110
+ copy exists for refresh continuity, not as a second authority, and putting the
111
+ token in a second place on disk buys nothing the envelope open does not.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Strict validator for ZCode's `provider_config.json`.
3
+ *
4
+ * ZCode parses this document with zod schemas compiled into its Electron
5
+ * bundle, and those schemas are `.strict()`: a single unrecognised key fails
6
+ * the WHOLE document, and the app's failure mode is to discard the entire
7
+ * personal provider set and start from an empty one — silently breaking every
8
+ * other provider the user had configured, with no error surfaced anywhere.
9
+ *
10
+ * That failure mode is why `setup` writes into a temp file and this validator
11
+ * runs before the write lands. It mirrors the shapes read out of
12
+ * `ZCode\resources\app.asar` -> `out/host/index.js`:
13
+ *
14
+ * manualProviderModelRules[].config (the `extractManualModelConfig` shape)
15
+ * enabled?
16
+ * properties pick: contextWindow, supportsJsonSchemaOutput,
17
+ * supportsNativeWebSearch, supportsMidConversationSystem
18
+ * + inputFormat pick: supportsImage, supportsVideo, supportsPdf
19
+ * optionSpecs pick: reasoningLevel
20
+ * + maxOutputTokens pick: max (map is NOT accepted here)
21
+ *
22
+ * providerModelRules[].config (the full base shape, sparse)
23
+ * properties: requiresMfjsToolSchema, contextWindow,
24
+ * inputFormat{5 flags}, outputFormat,
25
+ * supportsToolCall, supportsJsonSchemaOutput,
26
+ * supportsNativeWebSearch, supportsMidConversationSystem
27
+ * optionSpecs: reasoningLevel{values,map}, maxOutputTokens{max,map}
28
+ *
29
+ * Usage: node docs/validate-provider-config.mjs [path]
30
+ * Exit 0 = valid, 1 = invalid (with the offending path printed).
31
+ */
32
+ import { readFileSync } from 'node:fs'
33
+ import { join } from 'node:path'
34
+ import { homedir } from 'node:os'
35
+
36
+ const path = process.argv[2] ?? join(homedir(), '.zcode', 'v2', 'provider_config.json')
37
+
38
+ /** Keys accepted inside `providerModelRules[].config.properties`. */
39
+ const PROPERTY_KEYS = new Set([
40
+ 'requiresMfjsToolSchema', 'contextWindow',
41
+ 'inputFormat', 'outputFormat',
42
+ 'supportsToolCall', 'supportsJsonSchemaOutput',
43
+ 'supportsNativeWebSearch', 'supportsMidConversationSystem',
44
+ ])
45
+ const INPUT_FORMAT_KEYS = new Set(['supportsText', 'supportsImage', 'supportsVideo', 'supportsAudio', 'supportsPdf'])
46
+ const OUTPUT_FORMAT_KEYS = new Set(['supportsText'])
47
+ const OPTION_SPEC_KEYS = new Set(['reasoningLevel', 'maxOutputTokens'])
48
+ const MAX_OUTPUT_TOKEN_KEYS = new Set(['max', 'map'])
49
+ const REASONING_LEVEL_KEYS = new Set(['values', 'map'])
50
+
51
+ /** The manual variant accepts only a subset of each shape. */
52
+ const CONFIG_KEYS = new Set(['enabled', 'properties', 'optionSpecs'])
53
+ const MANUAL_PROPERTY_KEYS = new Set([
54
+ 'contextWindow', 'supportsJsonSchemaOutput',
55
+ 'supportsNativeWebSearch', 'supportsMidConversationSystem', 'inputFormat',
56
+ ])
57
+ const MANUAL_INPUT_FORMAT_KEYS = new Set(['supportsImage', 'supportsVideo', 'supportsPdf'])
58
+ const MANUAL_OPTION_SPEC_KEYS = new Set(['reasoningLevel', 'maxOutputTokens'])
59
+ const MANUAL_MAX_OUTPUT_TOKEN_KEYS = new Set(['max'])
60
+
61
+ const RULE_KEYS = new Set(['modelId', 'providerId', 'config'])
62
+ const PROVIDER_RULE_KEYS = new Set(['providerId', 'templateId', 'providerName', 'enabled', 'config'])
63
+ const PROVIDER_CONFIG_KEYS = new Set([
64
+ 'group', 'logo', 'access', 'api', 'builtinModelIds', 'personalModelIds', 'modelOrder', 'visibility',
65
+ ])
66
+ const API_TYPES = new Set(['anthropic-messages', 'openai-chat-completions', 'openai-responses'])
67
+ const MODEL_RULE_SETS = new Set([
68
+ 'providerModelRules', 'manualProviderModelRules', 'modelRules', 'modelApiRules',
69
+ 'providerSiteRules', 'templateModelRules', 'builtinProviderModelRules',
70
+ ])
71
+
72
+ const problems = []
73
+ const isObj = value => typeof value === 'object' && value !== null && !Array.isArray(value)
74
+
75
+ function unknownKeys(obj, allowed, where) {
76
+ if (!isObj(obj)) return
77
+ for (const key of Object.keys(obj)) {
78
+ if (!allowed.has(key)) problems.push(`${where}: unrecognised key "${key}" (strict schema rejects the whole document)`)
79
+ }
80
+ }
81
+
82
+ function checkPositiveInt(value, where) {
83
+ if (value === undefined) return
84
+ if (!Number.isInteger(value) || value <= 0) {
85
+ problems.push(`${where}: must be a positive integer, got ${JSON.stringify(value)}`)
86
+ }
87
+ }
88
+
89
+ function walkConfig(config, where, { manual }) {
90
+ if (!isObj(config)) {
91
+ problems.push(`${where}: must be an object`)
92
+ return
93
+ }
94
+ unknownKeys(config, CONFIG_KEYS, where)
95
+ if (config['enabled'] !== undefined && typeof config['enabled'] !== 'boolean') {
96
+ problems.push(`${where}.enabled: must be boolean`)
97
+ }
98
+
99
+ const properties = config['properties']
100
+ if (properties !== undefined) {
101
+ unknownKeys(properties, manual ? MANUAL_PROPERTY_KEYS : PROPERTY_KEYS, `${where}.properties`)
102
+ checkPositiveInt(properties['contextWindow'], `${where}.properties.contextWindow`)
103
+ if (properties['inputFormat'] !== undefined) {
104
+ unknownKeys(properties['inputFormat'], manual ? MANUAL_INPUT_FORMAT_KEYS : INPUT_FORMAT_KEYS, `${where}.properties.inputFormat`)
105
+ }
106
+ if (properties['outputFormat'] !== undefined) {
107
+ unknownKeys(properties['outputFormat'], OUTPUT_FORMAT_KEYS, `${where}.properties.outputFormat`)
108
+ }
109
+ }
110
+
111
+ const specs = config['optionSpecs']
112
+ if (specs === undefined) return
113
+ unknownKeys(specs, manual ? MANUAL_OPTION_SPEC_KEYS : OPTION_SPEC_KEYS, `${where}.optionSpecs`)
114
+ const maxOutput = specs['maxOutputTokens']
115
+ if (maxOutput !== undefined) {
116
+ unknownKeys(maxOutput, manual ? MANUAL_MAX_OUTPUT_TOKEN_KEYS : MAX_OUTPUT_TOKEN_KEYS, `${where}.optionSpecs.maxOutputTokens`)
117
+ checkPositiveInt(maxOutput['max'], `${where}.optionSpecs.maxOutputTokens.max`)
118
+ }
119
+ const reasoning = specs['reasoningLevel']
120
+ if (reasoning !== undefined) {
121
+ unknownKeys(reasoning, REASONING_LEVEL_KEYS, `${where}.optionSpecs.reasoningLevel`)
122
+ }
123
+ }
124
+
125
+ let document
126
+ try {
127
+ document = JSON.parse(readFileSync(path, 'utf8'))
128
+ } catch (error) {
129
+ console.error(`INVALID — ${path}`)
130
+ console.error(` - not readable as JSON: ${String(error)}`)
131
+ process.exit(1)
132
+ }
133
+
134
+ if (document['schemaVersion'] !== 1) {
135
+ problems.push(`schemaVersion: must be 1, got ${JSON.stringify(document['schemaVersion'])}`)
136
+ }
137
+ const config = document['config']
138
+ if (!isObj(config)) {
139
+ console.error(`INVALID — ${path}`)
140
+ console.error(' - missing config object')
141
+ process.exit(1)
142
+ }
143
+ unknownKeys(config, new Set(['providerOrder', 'providerConfigRules', 'modelConfigRules', 'defaultModelSelection']), 'config')
144
+
145
+ const modelConfigRules = config['modelConfigRules']
146
+ if (modelConfigRules !== undefined) {
147
+ unknownKeys(modelConfigRules, MODEL_RULE_SETS, 'config.modelConfigRules')
148
+ for (const setName of ['providerModelRules', 'manualProviderModelRules']) {
149
+ const rules = modelConfigRules[setName]
150
+ if (rules === undefined) continue
151
+ if (!Array.isArray(rules)) {
152
+ problems.push(`config.modelConfigRules.${setName}: must be an array`)
153
+ continue
154
+ }
155
+ rules.forEach((rule, index) => {
156
+ const where = `config.modelConfigRules.${setName}[${index}]`
157
+ unknownKeys(rule, RULE_KEYS, where)
158
+ if (typeof rule?.['providerId'] !== 'string') problems.push(`${where}.providerId: required string`)
159
+ if (typeof rule?.['modelId'] !== 'string') problems.push(`${where}.modelId: required string`)
160
+ if (rule?.['config'] !== undefined) {
161
+ walkConfig(rule['config'], `${where}.config`, { manual: setName === 'manualProviderModelRules' })
162
+ }
163
+ })
164
+ }
165
+ }
166
+
167
+ const providerRules = config['providerConfigRules']?.['providerRules']
168
+ if (providerRules !== undefined) {
169
+ if (!Array.isArray(providerRules)) {
170
+ problems.push('config.providerConfigRules.providerRules: must be an array')
171
+ } else {
172
+ providerRules.forEach((rule, index) => {
173
+ const where = `config.providerConfigRules.providerRules[${index}]`
174
+ unknownKeys(rule, PROVIDER_RULE_KEYS, where)
175
+ const ruleConfig = rule?.['config']
176
+ if (ruleConfig === undefined) return
177
+ unknownKeys(ruleConfig, PROVIDER_CONFIG_KEYS, `${where}.config`)
178
+ const ids = ruleConfig['personalModelIds']
179
+ if (ids !== undefined && ids !== null && !Array.isArray(ids)) {
180
+ problems.push(`${where}.config.personalModelIds: must be an array or null`)
181
+ }
182
+ const modelOrder = ruleConfig['modelOrder']
183
+ if (modelOrder !== undefined && modelOrder !== null && !Array.isArray(modelOrder)) {
184
+ problems.push(`${where}.config.modelOrder: must be an array or null`)
185
+ }
186
+ const api = ruleConfig['api']
187
+ if (isObj(api) && typeof api['type'] === 'string' && !API_TYPES.has(api['type'])) {
188
+ problems.push(`${where}.config.api.type: unsupported "${api['type']}"`)
189
+ }
190
+ })
191
+ }
192
+ }
193
+
194
+ if (problems.length === 0) {
195
+ const ruleCount = (modelConfigRules?.['providerModelRules']?.length ?? 0)
196
+ + (modelConfigRules?.['manualProviderModelRules']?.length ?? 0)
197
+ console.log(`VALID — ${path}`)
198
+ console.log(` providers: ${Array.isArray(providerRules) ? providerRules.length : 0}, model rules: ${ruleCount}`)
199
+ process.exit(0)
200
+ }
201
+ console.error(`INVALID — ${path}`)
202
+ for (const problem of problems) console.error(` - ${problem}`)
203
+ process.exit(1)
@@ -0,0 +1,162 @@
1
+ /**
2
+ * End-to-end verification of the zcode-workbuddy-connect endpoint.
3
+ *
4
+ * Run against a live endpoint: node docs/verify-endpoint.mjs
5
+ * Requires the service to be up on :39271 (node bin/cli.mjs serve).
6
+ *
7
+ * Covers the four failure modes that mattered during the port: stream
8
+ * termination (a missing [DONE] hangs the client), tool-call reassembly (the
9
+ * upstream sends id and name only on the first fragment), non-streaming
10
+ * fallback (the upstream rejects stream:false), and schema-valid JSON.
11
+ */
12
+ import { readFile } from 'node:fs/promises'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+
16
+ const stateDir = process.env['ZCODE_WORKBUDDY_CONNECT_HOME'] ?? join(homedir(), '.zcode-workbuddy-connect')
17
+ const token = JSON.parse(await readFile(join(stateDir, 'endpoint.json'), 'utf8')).token
18
+ const BASE = process.env['ZCODE_WORKBUDDY_CONNECT_BASE'] ?? 'http://127.0.0.1:39271/v1'
19
+ const H = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
20
+
21
+ let pass = 0, fail = 0
22
+ const check = (name, ok, detail = '') => {
23
+ console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ' — ' + detail : ''}`)
24
+ ok ? pass++ : fail++
25
+ }
26
+
27
+ /** POST and consume the SSE stream, reporting the first-byte latency. */
28
+ async function stream(body) {
29
+ const t0 = Date.now()
30
+ const r = await fetch(`${BASE}/chat/completions`, { method: 'POST', headers: H, body: JSON.stringify(body) })
31
+ if (r.status !== 200) {
32
+ const text = await r.text()
33
+ return { status: r.status, error: text.slice(0, 300) }
34
+ }
35
+ const reader = r.body.getReader()
36
+ const dec = new TextDecoder()
37
+ let firstByte = null, sawDone = false, sawHeartbeat = false
38
+ let text = '', reasoning = '', finish = null
39
+ const calls = []
40
+ let usage = null
41
+ for (;;) {
42
+ const { done, value } = await reader.read()
43
+ if (done) break
44
+ if (firstByte === null) firstByte = Date.now() - t0
45
+ for (const line of dec.decode(value, { stream: true }).split('\n')) {
46
+ const t = line.trim()
47
+ if (t.startsWith(':') && t.toLowerCase().includes('heartbeat')) { sawHeartbeat = true; continue }
48
+ if (!t.startsWith('data:')) continue
49
+ const p = t.slice(5).trim()
50
+ if (p === '') continue
51
+ if (p === '[DONE]') { sawDone = true; continue }
52
+ let j; try { j = JSON.parse(p) } catch { continue }
53
+ if (j.usage) usage = j.usage
54
+ const ch = j.choices?.[0]
55
+ if (!ch) continue
56
+ if (ch.finish_reason) finish = ch.finish_reason
57
+ const d = ch.delta
58
+ if (!d) continue
59
+ if (typeof d.content === 'string') text += d.content
60
+ if (typeof d.reasoning_content === 'string') reasoning += d.reasoning_content
61
+ if (Array.isArray(d.tool_calls)) calls.push(...d.tool_calls)
62
+ }
63
+ }
64
+ // Merge tool-call fragments by index, exactly as a real OpenAI client does:
65
+ // id and name arrive on the first fragment, arguments accumulate after.
66
+ const byIndex = new Map()
67
+ for (const c of calls) {
68
+ const i = typeof c.index === 'number' ? c.index : 0
69
+ const acc = byIndex.get(i) ?? { id: '', name: '', arguments: '' }
70
+ if (typeof c.id === 'string' && c.id !== '') acc.id = c.id
71
+ const fn = c.function
72
+ if (fn) {
73
+ if (typeof fn.name === 'string' && fn.name !== '') acc.name = fn.name
74
+ if (typeof fn.arguments === 'string') acc.arguments += fn.arguments
75
+ }
76
+ byIndex.set(i, acc)
77
+ }
78
+ return {
79
+ status: 200, firstByte, total: Date.now() - t0, sawDone, sawHeartbeat, text, reasoning, finish, usage,
80
+ rawChunks: calls.length,
81
+ calls: [...byIndex.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => ({ id: v.id, function: { name: v.name, arguments: v.arguments } })),
82
+ }
83
+ }
84
+
85
+ const TOOLS = [{
86
+ type: 'function',
87
+ function: {
88
+ name: 'get_weather',
89
+ description: 'Get the current weather for a city',
90
+ parameters: { type: 'object', properties: { city: { type: 'string' }, unit: { type: 'string' } }, required: ['city'] },
91
+ },
92
+ }]
93
+
94
+ console.log('=== 1. streaming text ===')
95
+ {
96
+ const r = await stream({ model: 'hy3', stream: true, messages: [{ role: 'user', content: 'Reply with exactly: PONG' }] })
97
+ check('HTTP 200', r.status === 200, r.status === 200 ? `firstByte ${r.firstByte}ms, total ${r.total}ms` : r.error)
98
+ check('terminated with [DONE]', r.sawDone === true)
99
+ check('content received', (r.text ?? '').trim().length > 0, JSON.stringify((r.text ?? '').trim().slice(0, 40)))
100
+ check('finish_reason stop', r.finish === 'stop', JSON.stringify(r.finish))
101
+ check('usage reported', r.usage != null, `${r.usage?.total_tokens} tokens`)
102
+ }
103
+
104
+ console.log('\n=== 2. streaming tool call (id + name must survive) ===')
105
+ {
106
+ const r = await stream({
107
+ model: 'hy3', stream: true, tool_choice: 'get_weather', tools: TOOLS,
108
+ messages: [
109
+ { role: 'system', content: 'You must call the provided tool.' },
110
+ { role: 'user', content: 'What is the weather in Shanghai in celsius?' },
111
+ ],
112
+ })
113
+ check('HTTP 200', r.status === 200, r.status === 200 ? `firstByte ${r.firstByte}ms` : r.error)
114
+ check('finish_reason is tool_calls', r.finish === 'tool_calls', JSON.stringify(r.finish))
115
+ check('exactly one assembled call', r.calls.length === 1, `${r.calls.length} call(s) from ${r.rawChunks} chunk(s)`)
116
+ const c = r.calls[0]
117
+ check('call has an upstream id', typeof c?.id === 'string' && c.id.length > 0, JSON.stringify(c?.id))
118
+ check('call has the function name', c?.function?.name === 'get_weather', JSON.stringify(c?.function?.name))
119
+ let args = null
120
+ try { args = JSON.parse(c?.function?.arguments ?? '') } catch {}
121
+ check('arguments are complete JSON', args !== null, c?.function?.arguments)
122
+ check('arguments carry the city', args?.city === 'Shanghai', JSON.stringify(args))
123
+ }
124
+
125
+ console.log('\n=== 3. non-streaming ===')
126
+ {
127
+ const r = await fetch(`${BASE}/chat/completions`, {
128
+ method: 'POST', headers: H,
129
+ body: JSON.stringify({ model: 'hy3', stream: false, messages: [{ role: 'user', content: 'Reply with exactly: JSONOK' }] }),
130
+ })
131
+ const j = await r.json()
132
+ check('HTTP 200', r.status === 200, r.status === 200 ? '' : JSON.stringify(j).slice(0, 200))
133
+ check('object is chat.completion', j.object === 'chat.completion', j.object)
134
+ check('message content present', typeof j.choices?.[0]?.message?.content === 'string' && j.choices[0].message.content.trim().length > 0,
135
+ JSON.stringify(j.choices?.[0]?.message?.content?.trim()?.slice(0, 30)))
136
+ check('usage reported', j.usage != null, `${j.usage?.total_tokens} tokens`)
137
+ }
138
+
139
+ console.log('\n=== 4. non-streaming tool call ===')
140
+ {
141
+ const r = await fetch(`${BASE}/chat/completions`, {
142
+ method: 'POST', headers: H,
143
+ body: JSON.stringify({
144
+ model: 'hy3', stream: false, tool_choice: 'get_weather', tools: TOOLS,
145
+ messages: [
146
+ { role: 'system', content: 'You must call the provided tool.' },
147
+ { role: 'user', content: 'What is the weather in Beijing?' },
148
+ ],
149
+ }),
150
+ })
151
+ const j = await r.json()
152
+ const tc = j.choices?.[0]?.message?.tool_calls
153
+ check('tool_calls present', Array.isArray(tc) && tc.length === 1, `${tc?.length}`)
154
+ check('tool name correct', tc?.[0]?.function?.name === 'get_weather', JSON.stringify(tc?.[0]?.function?.name))
155
+ check('finish_reason tool_calls', j.choices?.[0]?.finish_reason === 'tool_calls', j.choices?.[0]?.finish_reason)
156
+ let args = null
157
+ try { args = JSON.parse(tc?.[0]?.function?.arguments ?? '') } catch {}
158
+ check('arguments parse', args !== null, JSON.stringify(args))
159
+ }
160
+
161
+ console.log(`\n${pass} passed, ${fail} failed`)
162
+ process.exitCode = fail === 0 ? 0 : 1
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart hook: make sure the local WorkBuddy endpoint is running.
4
+ *
5
+ * Contract with the rest of the plugin: the provider entry in ZCode's
6
+ * provider_config.json points at a fixed loopback port, so something has to be
7
+ * listening there before the first message. This hook starts it if it is not
8
+ * already up.
9
+ *
10
+ * It is deliberately *self-verifying*: spawning a detached process is the part
11
+ * most likely to be refused (a locked-down or sandboxed host can deny it
12
+ * outright), so the hook never reports success on the strength of the spawn
13
+ * call alone. It probes the endpoint afterwards and says plainly what happened,
14
+ * because a hook that claims the service is up while it is not would turn a
15
+ * visible "start it yourself" into a confusing connection error later.
16
+ *
17
+ * Never fails the session: every path exits 0.
18
+ *
19
+ * @module zcode-workbuddy-connect/hooks/service-ensure
20
+ */
21
+
22
+ import { spawn } from 'node:child_process'
23
+ import { readFile, appendFile, mkdir } from 'node:fs/promises'
24
+ import { existsSync } from 'node:fs'
25
+ import { dirname, join } from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+
28
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)))
29
+ const cliPath = join(pluginRoot, 'bin', 'cli.mjs')
30
+ const stateDir = process.env['ZCODE_WORKBUDDY_CONNECT_HOME'] ?? join(process.env['USERPROFILE'] ?? '.', '.zcode-workbuddy-connect')
31
+ const endpointPath = join(stateDir, 'endpoint.json')
32
+ const logPath = join(stateDir, 'service.log')
33
+ const DEFAULT_PORT = 39271
34
+
35
+ async function log(line) {
36
+ try {
37
+ await mkdir(stateDir, { recursive: true })
38
+ await appendFile(logPath, `${new Date().toISOString()} ${line}\n`)
39
+ } catch {
40
+ // Diagnostics are best-effort; never let logging break the hook.
41
+ }
42
+ }
43
+
44
+ async function readEndpoint() {
45
+ try {
46
+ const document = JSON.parse(await readFile(endpointPath, 'utf8'))
47
+ const token = typeof document['token'] === 'string' ? document['token'] : ''
48
+ const port = typeof document['port'] === 'number' && document['port'] > 0 ? document['port'] : DEFAULT_PORT
49
+ return { token, port }
50
+ } catch {
51
+ return { token: '', port: DEFAULT_PORT }
52
+ }
53
+ }
54
+
55
+ /** Probe the endpoint; true only when it answers healthily. */
56
+ async function healthy(port, token, timeoutMs = 2500) {
57
+ try {
58
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`, {
59
+ headers: token === '' ? {} : { Authorization: `Bearer ${token}` },
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ })
62
+ if (!response.ok) return false
63
+ const body = await response.json()
64
+ return body?.['ok'] === true
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ async function waitForHealth(port, token, attempts = 20) {
71
+ for (let i = 0; i < attempts; i += 1) {
72
+ if (await healthy(port, token)) return true
73
+ await new Promise(resolve => setTimeout(resolve, 500))
74
+ }
75
+ return false
76
+ }
77
+
78
+ /** Is a process already bound to the port (someone else's, or a stale child)? */
79
+ function spawnDetached(port) {
80
+ // `windowsHide` plus `stdio: 'ignore'` is the whole point: the endpoint must
81
+ // not flash a console window on the user's desktop every session.
82
+ const child = spawn(process.execPath, [cliPath, 'serve', '--port', String(port)], {
83
+ detached: true,
84
+ stdio: 'ignore',
85
+ windowsHide: true,
86
+ cwd: pluginRoot,
87
+ })
88
+ child.unref()
89
+ return child.pid
90
+ }
91
+
92
+ async function main() {
93
+ if (!existsSync(cliPath)) {
94
+ await log(`cli not found at ${cliPath}; skipping`)
95
+ return
96
+ }
97
+ const { token, port } = await readEndpoint()
98
+
99
+ if (await healthy(port, token)) {
100
+ await log(`endpoint already healthy on :${port}`)
101
+ return
102
+ }
103
+
104
+ let pid
105
+ try {
106
+ pid = spawnDetached(port)
107
+ await log(`spawned detached pid ${pid} on :${port}`)
108
+ } catch (error) {
109
+ await log(`spawn refused: ${String(error)}`)
110
+ }
111
+
112
+ if (await waitForHealth(port, token)) {
113
+ await log(`endpoint healthy on :${port}${pid === undefined ? '' : ` (pid ${pid})`}`)
114
+ return
115
+ }
116
+
117
+ // The endpoint is not up and the hook could not bring it up. Say so, with the
118
+ // one command that fixes it, rather than leaving the failure to surface as an
119
+ // unexplained connection error on the first message.
120
+ await log(`endpoint NOT healthy on :${port} after spawn attempt`)
121
+ console.log(
122
+ `[zcode-workbuddy-connect] The local endpoint is not running on port ${port}.`
123
+ + ' Its models will fail until it is started. Start it in a terminal with:\n'
124
+ + ` node "${cliPath}" serve --port ${port}\n`
125
+ + `Diagnostics: node "${cliPath}" doctor`,
126
+ )
127
+ }
128
+
129
+ main().catch(async error => {
130
+ await log(`hook error: ${String(error)}`)
131
+ }).finally(() => {
132
+ process.exit(0)
133
+ })
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "zcode-workbuddy-connect",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Expose WorkBuddy desktop-app models (CN) through a loopback OpenAI-compatible endpoint, for use as a model provider in ZCode or any OpenAI-compatible client.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "xiao-hans"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/xiao-hans/zcode-ai-connect.git",
13
+ "directory": "zcode-workbuddy-connect"
14
+ },
15
+ "homepage": "https://github.com/xiao-hans/zcode-ai-connect/tree/main/zcode-workbuddy-connect#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/xiao-hans/zcode-ai-connect/issues"
18
+ },
19
+ "os": [
20
+ "win32"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18.17.0"
24
+ },
25
+ "bin": {
26
+ "zcode-workbuddy-connect": "bin/cli.mjs"
27
+ },
28
+ "files": [
29
+ ".zcode-plugin",
30
+ "bin",
31
+ "src",
32
+ "hooks",
33
+ "skills",
34
+ "docs",
35
+ "LICENSE",
36
+ "NOTICE.md",
37
+ "README.md",
38
+ "!src/server.js.bak-order"
39
+ ],
40
+ "exports": {
41
+ ".": "./src/upstream.js",
42
+ "./server": "./src/server.js",
43
+ "./auth": "./src/auth.js",
44
+ "./catalog": "./src/catalog.js",
45
+ "./desktop-credential-protection": "./src/desktop-credential-protection.js"
46
+ },
47
+ "scripts": {
48
+ "serve": "node bin/cli.mjs serve",
49
+ "status": "node bin/cli.mjs status",
50
+ "doctor": "node bin/cli.mjs doctor",
51
+ "test": "node --test"
52
+ },
53
+ "dependencies": {},
54
+ "keywords": [
55
+ "workbuddy",
56
+ "codebuddy",
57
+ "openai-compatible",
58
+ "provider",
59
+ "zcode"
60
+ ]
61
+ }