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,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
+ }