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.
- package/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +22 -0
- package/NOTICE.md +49 -0
- package/README.md +264 -2
- package/bin/cli.mjs +8 -0
- package/docs/adr/0001-loopback-provider-for-zcode.md +66 -0
- package/docs/adr/0002-widest-window-and-output-ceiling.md +98 -0
- package/docs/adr/0003-region-by-url-path.md +97 -0
- package/docs/adr/0004-workbuddy-56-at-rest-credentials.md +111 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/docs/verify-endpoint.mjs +162 -0
- package/hooks/service-ensure.mjs +133 -0
- package/package.json +59 -4
- package/skills/workbuddy-models/SKILL.md +107 -0
- package/src/app-version.js +119 -0
- package/src/auth.js +441 -0
- package/src/catalog.js +100 -0
- package/src/cli.js +1010 -0
- package/src/client-identity.js +213 -0
- package/src/config.js +91 -0
- package/src/desktop-credential-protection.js +890 -0
- package/src/loopback.js +40 -0
- package/src/server.js +873 -0
- package/src/upstream.js +762 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop-client identity for chat requests.
|
|
3
|
+
*
|
|
4
|
+
* Chat carries the User-Agent shape the official desktop client composes —
|
|
5
|
+
* `WorkBuddy/<v> WorkBuddy/<v>` — where the product token names the app that
|
|
6
|
+
* owns the region's requests. Versions come from the installed App when it can
|
|
7
|
+
* be read, degrade to a saved value, and finally to a compiled-in constant.
|
|
8
|
+
* Never the legacy CLI UA: the upstream gateway has been observed to reject
|
|
9
|
+
* that shape for chat.
|
|
10
|
+
*
|
|
11
|
+
* Scope: chat requests ONLY. Refresh, catalog and billing keep the CLI-form UA.
|
|
12
|
+
*
|
|
13
|
+
* @module zcode-workbuddy-connect/client-identity
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { savedVersionPath } from './config.js'
|
|
19
|
+
import { FALLBACK_APP_VERSION, FALLBACK_CN_APP_VERSION, readBundleVersion, resolveAppVersion, validAppVersion } from './app-version.js'
|
|
20
|
+
|
|
21
|
+
/** Basename of the CN saved-version cache. */
|
|
22
|
+
export const CN_APP_VERSION_FILENAME = '.workbuddy-app-version.json'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a value is a CLI version that may reach a header. Tolerates a
|
|
26
|
+
* prerelease suffix; anything with whitespace, CR or LF never passes.
|
|
27
|
+
*/
|
|
28
|
+
export function validCliVersion(value) {
|
|
29
|
+
return typeof value === 'string' && /^\d{1,6}(?:\.\d{1,6}){1,3}(?:-[0-9A-Za-z.]+)?$/u.test(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Path of the bundled agent CLI's package.json inside an installed App.
|
|
34
|
+
* On Windows the App ships unpacked resources next to the executable; on
|
|
35
|
+
* macOS it is `app.asar.unpacked` inside the bundle.
|
|
36
|
+
*/
|
|
37
|
+
function cliPackagePaths(root) {
|
|
38
|
+
return process.platform === 'darwin'
|
|
39
|
+
? [join(root, 'Contents', 'Resources', 'app.asar.unpacked', 'cli', 'package.json')]
|
|
40
|
+
: [
|
|
41
|
+
join(root, 'resources', 'app.asar.unpacked', 'cli', 'package.json'),
|
|
42
|
+
join(root, 'resources', 'cli', 'package.json'),
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The bundled agent CLI's real version, or undefined when it does not resolve.
|
|
48
|
+
*
|
|
49
|
+
* `cli/package.json` ships a `0.0.0` placeholder in `version` with the real
|
|
50
|
+
* version in `publishConfig.customPackage.version`; a valid non-placeholder
|
|
51
|
+
* `version` wins, otherwise the custom-package value applies, and unreadable
|
|
52
|
+
* or invalid metadata yields undefined (the caller drops the `CLI/…` token
|
|
53
|
+
* rather than guessing).
|
|
54
|
+
*/
|
|
55
|
+
export async function readCliVersion(root) {
|
|
56
|
+
for (const path of cliPackagePaths(root)) {
|
|
57
|
+
let document
|
|
58
|
+
try {
|
|
59
|
+
document = JSON.parse(await readFile(path, 'utf8'))
|
|
60
|
+
} catch {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (typeof document !== 'object' || document === null || Array.isArray(document)) continue
|
|
64
|
+
const declared = document['version']
|
|
65
|
+
if (validCliVersion(declared) && declared !== '0.0.0') return declared
|
|
66
|
+
const publishConfig = document['publishConfig']
|
|
67
|
+
const customPackage = typeof publishConfig === 'object' && publishConfig !== null && !Array.isArray(publishConfig)
|
|
68
|
+
? publishConfig['customPackage'] : undefined
|
|
69
|
+
const customVersion = typeof customPackage === 'object' && customPackage !== null ? customPackage['version'] : undefined
|
|
70
|
+
if (validCliVersion(customVersion)) return customVersion
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the chat User-Agent for one region.
|
|
77
|
+
*
|
|
78
|
+
* Throws on an invalid version rather than interpolating one into a header;
|
|
79
|
+
* `resolveChatIdentity` never produces such an identity, so the throw is a
|
|
80
|
+
* last gate against future call-site mistakes, not an expected path.
|
|
81
|
+
*/
|
|
82
|
+
export function chatUserAgent(identity, region) {
|
|
83
|
+
if (!validAppVersion(identity.clientVersion)) {
|
|
84
|
+
throw new Error(`invalid client version for chat User-Agent: ${JSON.stringify(identity.clientVersion)}`)
|
|
85
|
+
}
|
|
86
|
+
if (identity.cliVersion !== undefined && !validCliVersion(identity.cliVersion)) {
|
|
87
|
+
throw new Error(`invalid CLI version for chat User-Agent: ${JSON.stringify(identity.cliVersion)}`)
|
|
88
|
+
}
|
|
89
|
+
const product = region === 'global' ? 'WorkBuddy AI' : 'WorkBuddy'
|
|
90
|
+
const parts = [`WorkBuddy/${identity.clientVersion}`, `${product}/${identity.clientVersion}`]
|
|
91
|
+
if (identity.cliVersion !== undefined) parts.push(`CLI/${identity.cliVersion}`)
|
|
92
|
+
return parts.join(' ')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The region's compiled-in fallback identity: desktop form, no CLI segment. */
|
|
96
|
+
export function fallbackChatIdentity(region) {
|
|
97
|
+
return { clientVersion: region === 'global' ? FALLBACK_APP_VERSION : FALLBACK_CN_APP_VERSION }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the installed CN desktop App in the conventional install roots.
|
|
102
|
+
* Mirrors the macOS probe with Windows locations; a missing App is normal.
|
|
103
|
+
*/
|
|
104
|
+
async function installedCnApp() {
|
|
105
|
+
const candidates = []
|
|
106
|
+
if (process.platform === 'darwin') {
|
|
107
|
+
candidates.push('/Applications/WorkBuddy.app')
|
|
108
|
+
} else if (process.platform === 'win32') {
|
|
109
|
+
const local = process.env['LOCALAPPDATA']
|
|
110
|
+
const roaming = process.env['APPDATA']
|
|
111
|
+
const programFiles = process.env['ProgramFiles'] ?? 'C:/Program Files'
|
|
112
|
+
const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:/Program Files (x86)'
|
|
113
|
+
for (const base of [local && join(local, 'Programs'), roaming && join(roaming, 'Programs'), programFiles, programFilesX86]) {
|
|
114
|
+
if (!base) continue
|
|
115
|
+
candidates.push(join(base, 'WorkBuddy'), join(base, 'WorkBuddy AI'))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const root of candidates) {
|
|
119
|
+
for (const infoPath of [
|
|
120
|
+
join(root, 'resources', 'app', 'package.json'),
|
|
121
|
+
join(root, 'Contents', 'Info.plist'),
|
|
122
|
+
]) {
|
|
123
|
+
const version = await readBundleVersion(infoPath)
|
|
124
|
+
if (version !== undefined && validAppVersion(version)) return { version, root }
|
|
125
|
+
}
|
|
126
|
+
// Windows Apps typically carry the version on the executable's file name
|
|
127
|
+
// nowhere; a plain `package.json` at the root is the common fallback.
|
|
128
|
+
try {
|
|
129
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
130
|
+
if (validAppVersion(pkg?.['version'])) return { version: pkg['version'], root }
|
|
131
|
+
} catch {
|
|
132
|
+
// not installed here
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const cache = new Map()
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the chat identity for one region: installed App → region's saved
|
|
142
|
+
* value → compiled-in fallback. Never throws — a missing App, an unreadable
|
|
143
|
+
* manifest, a failed cache write, or a reader that throws outright all
|
|
144
|
+
* degrade to {@link fallbackChatIdentity}; resolution never blocks a message.
|
|
145
|
+
*/
|
|
146
|
+
export async function resolveChatIdentity(region, options = {}) {
|
|
147
|
+
const injectable = options.installedCn !== undefined
|
|
148
|
+
|| options.resolveIntl !== undefined
|
|
149
|
+
|| options.cliVersion !== undefined
|
|
150
|
+
|| options.cnSavedPath !== undefined
|
|
151
|
+
if (!injectable) {
|
|
152
|
+
const cached = cache.get(region)
|
|
153
|
+
if (cached !== undefined) return cached
|
|
154
|
+
}
|
|
155
|
+
let identity
|
|
156
|
+
try {
|
|
157
|
+
identity = region === 'global'
|
|
158
|
+
? await resolveGlobalIdentity(options)
|
|
159
|
+
: await resolveCnIdentity(options)
|
|
160
|
+
} catch {
|
|
161
|
+
// A reader that throws must not block a message and must not pin this
|
|
162
|
+
// degraded answer for the process lifetime: return the fallback without
|
|
163
|
+
// caching it, so a later call can resolve properly again.
|
|
164
|
+
return fallbackChatIdentity(region)
|
|
165
|
+
}
|
|
166
|
+
if (!injectable) cache.set(region, identity)
|
|
167
|
+
return identity
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** CN: installed App → CN saved cache → CN fallback. */
|
|
171
|
+
async function resolveCnIdentity(options) {
|
|
172
|
+
const savedPath = options.cnSavedPath ?? savedVersionPath()
|
|
173
|
+
const installed = await (options.installedCn ?? installedCnApp)()
|
|
174
|
+
if (installed !== undefined && validAppVersion(installed.version)) {
|
|
175
|
+
const cliVersion = await (options.cliVersion ?? readCliVersion)(installed.root)
|
|
176
|
+
const identity = {
|
|
177
|
+
clientVersion: installed.version,
|
|
178
|
+
...(cliVersion !== undefined && validCliVersion(cliVersion) ? { cliVersion } : {}),
|
|
179
|
+
}
|
|
180
|
+
// Best-effort remember of the App version only: the CLI version is read
|
|
181
|
+
// live from the install tree whenever it exists, and once the App is gone
|
|
182
|
+
// the degradation omits the `CLI/…` token rather than continuing to claim
|
|
183
|
+
// a version whose source no longer exists.
|
|
184
|
+
try {
|
|
185
|
+
await mkdir(dirname(savedPath), { recursive: true })
|
|
186
|
+
await writeFile(savedPath, `${JSON.stringify({ version: identity.clientVersion, observedAt: Date.now() }, null, 2)}\n`, { mode: 0o600 })
|
|
187
|
+
} catch {
|
|
188
|
+
// Identity resolved; losing the memory of it is not a failure.
|
|
189
|
+
}
|
|
190
|
+
return identity
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const saved = JSON.parse(await readFile(savedPath, 'utf8'))
|
|
194
|
+
if (typeof saved === 'object' && saved !== null && validAppVersion(saved['version'])) {
|
|
195
|
+
return { clientVersion: saved['version'] }
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Absent or malformed cache: fall through to the compiled-in constant.
|
|
199
|
+
}
|
|
200
|
+
return fallbackChatIdentity('cn')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** International: `app-version.js`'s installed → saved → fallback chain. */
|
|
204
|
+
async function resolveGlobalIdentity(options) {
|
|
205
|
+
const info = await (options.resolveIntl ?? resolveAppVersion)()
|
|
206
|
+
const clientVersion = validAppVersion(info.version) ? info.version : FALLBACK_APP_VERSION
|
|
207
|
+
let cliVersion
|
|
208
|
+
if (info.root !== undefined) {
|
|
209
|
+
const read = await (options.cliVersion ?? readCliVersion)(info.root)
|
|
210
|
+
if (read !== undefined && validCliVersion(read)) cliVersion = read
|
|
211
|
+
}
|
|
212
|
+
return { clientVersion, ...(cliVersion === undefined ? {} : { cliVersion }) }
|
|
213
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime paths shared across the service. Resolved once at startup so the
|
|
3
|
+
* chat hot path never re-derives them per request.
|
|
4
|
+
*
|
|
5
|
+
* @module zcode-workbuddy-connect/config
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Root of everything this service owns on disk.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately outside any harness's own home: the credential copy and the
|
|
15
|
+
* version caches are this service's state, and nothing here writes into the
|
|
16
|
+
* WorkBuddy desktop app's files.
|
|
17
|
+
*/
|
|
18
|
+
export function defaultStateDir() {
|
|
19
|
+
const fromEnv = process.env['ZCODE_WORKBUDDY_CONNECT_HOME']
|
|
20
|
+
if (fromEnv !== undefined && fromEnv.trim() !== '') return fromEnv
|
|
21
|
+
return join(homedir(), '.zcode-workbuddy-connect')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const state = {
|
|
25
|
+
stateDir: defaultStateDir(),
|
|
26
|
+
/** Fixed loopback port; 0 asks the OS for a free port. */
|
|
27
|
+
port: 0,
|
|
28
|
+
/** Bearer the OpenAI-compatible endpoint requires. */
|
|
29
|
+
token: '',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Point the service's state (credential copy, caches) at a directory. */
|
|
33
|
+
export function configureStateDir(dir) {
|
|
34
|
+
if (typeof dir === 'string' && dir !== '') state.stateDir = dir
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function stateDir() {
|
|
38
|
+
return state.stateDir
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function configureEndpoint({ port, token } = {}) {
|
|
42
|
+
if (typeof port === 'number' && Number.isInteger(port) && port >= 0 && port <= 65535) state.port = port
|
|
43
|
+
if (typeof token === 'string' && token !== '') state.token = token
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function endpointConfig() {
|
|
47
|
+
return { port: state.port, token: state.token }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Path of the plugin-owned CN credential copy. */
|
|
51
|
+
export function ownAuthPath() {
|
|
52
|
+
return join(state.stateDir, '.workbuddy-auth.json')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Path of the plugin-owned international credential copy. */
|
|
56
|
+
export function ownAuthAiPath() {
|
|
57
|
+
return join(state.stateDir, '.workbuddy-ai-auth.json')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Path of the saved App-version cache. */
|
|
61
|
+
export function savedVersionPath() {
|
|
62
|
+
return join(state.stateDir, '.workbuddy-app-version.json')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Path of the persisted endpoint settings (port and bearer). */
|
|
66
|
+
export function endpointPath() {
|
|
67
|
+
return join(state.stateDir, 'endpoint.json')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The endpoint's bearer, generated once and persisted.
|
|
72
|
+
*
|
|
73
|
+
* It has to be a *stable* value rather than a per-process secret: the client
|
|
74
|
+
* that points at this endpoint records its bearer in its own configuration
|
|
75
|
+
* file, so a token rotating on every start would break it on restart. It is
|
|
76
|
+
* stored under the state directory (mode 0600) next to the credential copy,
|
|
77
|
+
* and never travels upstream — the upstream credential is resolved separately
|
|
78
|
+
* from the WorkBuddy auth file.
|
|
79
|
+
*/
|
|
80
|
+
export function loadToken(fallback) {
|
|
81
|
+
if (state.token !== '') return state.token
|
|
82
|
+
if (typeof fallback === 'string' && fallback !== '') {
|
|
83
|
+
state.token = fallback
|
|
84
|
+
return state.token
|
|
85
|
+
}
|
|
86
|
+
return ''
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function setToken(token) {
|
|
90
|
+
if (typeof token === 'string') state.token = token
|
|
91
|
+
}
|