@xingular/harness 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Xingular Harness \u2014 a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
6
6
  "exports": {
@@ -0,0 +1,95 @@
1
+ import { existsSync, lstatSync, linkSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { expect, test } from 'vitest'
5
+
6
+ import { configDir, ensureSharedDir, linkSharedEntries, sharedDir, type EntryLinker } from './env'
7
+
8
+ const temp = () => mkdtempSync(join(tmpdir(), 'harness-env-'))
9
+ const refused = (code = 'EPERM') => Object.assign(new Error(`${code}: operation not permitted, symlink`), { code })
10
+ const backups = (folder: string) => readdirSync(folder).filter((name) => name.includes('agentx-backup'))
11
+
12
+ // A Windows account without the symlink privilege: file symlinks are refused, junctions and hard links work.
13
+ const windowsLike: EntryLinker = {
14
+ symlink: (target, path, isDir) => {
15
+ if (!isDir) throw refused()
16
+ symlinkSync(target, path)
17
+ },
18
+ hardLink: (target, path) => linkSync(target, path)
19
+ }
20
+
21
+ const refusesAll: EntryLinker = {
22
+ symlink: () => {
23
+ throw refused()
24
+ },
25
+ hardLink: () => {
26
+ throw refused()
27
+ }
28
+ }
29
+
30
+ test('without the symlink privilege, files are hard-linked and folders are junctioned', () => {
31
+ const dir = temp()
32
+ const home = configDir(dir, { id: 'acc1' })
33
+ mkdirSync(home, { recursive: true })
34
+ writeFileSync(join(home, 'settings.json'), '{"theme":"dark"}')
35
+
36
+ const first = linkSharedEntries(dir, { id: 'acc1' }, windowsLike)
37
+ const shared = join(sharedDir(dir), 'settings.json')
38
+ expect(first.entries.find((entry) => entry.name === 'settings.json')).toEqual({ name: 'settings.json', state: 'linked' })
39
+ expect(lstatSync(join(home, 'settings.json')).ino).toBe(lstatSync(shared).ino)
40
+ expect(lstatSync(join(home, 'settings.json')).isSymbolicLink()).toBe(false)
41
+ expect(lstatSync(join(home, 'skills')).isSymbolicLink()).toBe(true)
42
+
43
+ // Entries with no shared copy yet cannot be hard-linked, so they stay the account's own.
44
+ expect(first.entries.find((entry) => entry.name === 'AGENTS.md')).toMatchObject({ state: 'unshared' })
45
+
46
+ // A second launch finds the links in place and moves nothing aside.
47
+ const second = linkSharedEntries(dir, { id: 'acc1' }, windowsLike)
48
+ expect(second.entries.find((entry) => entry.name === 'settings.json')?.state).toBe('linked')
49
+ expect(second.entries.find((entry) => entry.name === 'skills')?.state).toBe('linked')
50
+ expect(backups(home)).toEqual([])
51
+ expect(backups(sharedDir(dir))).toEqual([])
52
+ expect(readFileSync(join(home, 'settings.json'), 'utf8')).toBe('{"theme":"dark"}')
53
+ })
54
+
55
+ test('when links are refused, the account keeps its own settings', () => {
56
+ const dir = temp()
57
+ const home = configDir(dir, { id: 'acc1' })
58
+ mkdirSync(home, { recursive: true })
59
+ writeFileSync(join(home, 'settings.json'), '{"mine":true}')
60
+
61
+ const { entries } = linkSharedEntries(dir, { id: 'acc1' }, refusesAll)
62
+ expect(entries.find((entry) => entry.name === 'settings.json')).toEqual({ name: 'settings.json', state: 'unshared', reason: 'EPERM' })
63
+ expect(readFileSync(join(home, 'settings.json'), 'utf8')).toBe('{"mine":true}')
64
+ expect(existsSync(join(sharedDir(dir), 'settings.json'))).toBe(false)
65
+ expect(backups(home)).toEqual([])
66
+ })
67
+
68
+ test('when links are refused, a shared file the account lacks is copied in and stays put', () => {
69
+ const dir = temp()
70
+ writeFileSync(join(ensureSharedDir(dir), 'settings.json'), '{"shared":true}')
71
+ const home = configDir(dir, { id: 'acc2' })
72
+
73
+ const first = linkSharedEntries(dir, { id: 'acc2' }, refusesAll)
74
+ expect(readFileSync(join(home, 'settings.json'), 'utf8')).toBe('{"shared":true}')
75
+ expect(first.entries.find((entry) => entry.name === 'settings.json')?.state).toBe('unshared')
76
+
77
+ const second = linkSharedEntries(dir, { id: 'acc2' }, refusesAll)
78
+ expect(second.entries.find((entry) => entry.name === 'settings.json')?.state).toBe('unshared')
79
+ expect(readFileSync(join(home, 'settings.json'), 'utf8')).toBe('{"shared":true}')
80
+ expect(backups(home)).toEqual([])
81
+ expect(backups(sharedDir(dir))).toEqual([])
82
+ })
83
+
84
+ test('a refused link leaves the account\'s existing link as it was', () => {
85
+ const dir = temp()
86
+ const home = configDir(dir, { id: 'acc3' })
87
+ mkdirSync(home, { recursive: true })
88
+ const elsewhere = join(temp(), 'skills')
89
+ mkdirSync(elsewhere)
90
+ symlinkSync(elsewhere, join(home, 'skills'))
91
+
92
+ const { entries } = linkSharedEntries(dir, { id: 'acc3' }, refusesAll)
93
+ expect(entries.find((entry) => entry.name === 'skills')?.state).toBe('unshared')
94
+ expect(readlinkSync(join(home, 'skills'))).toBe(elsewhere)
95
+ })
@@ -3,11 +3,12 @@
3
3
  // variables are removed. A launch that would fall back to `~/.pi` is refused.
4
4
  //
5
5
  // Settings, skills, prompts, and instructions are shared by every account: the account's agent
6
- // folder links those entries to `_shared/pi/`. Credentials, sessions, and history stay in the
7
- // account's own folder.
8
- import { chmodSync, lstatSync, mkdirSync, readlinkSync, renameSync, symlinkSync, unlinkSync } from 'node:fs'
6
+ // folder links those entries to `_shared/pi/`. Where the system refuses a link (Windows without
7
+ // the symlink privilege), the entry stays the account's own, and a shared file is copied in so
8
+ // its settings still load. Credentials, sessions, and history stay in the account's own folder.
9
+ import { chmodSync, copyFileSync, linkSync, lstatSync, mkdirSync, readlinkSync, renameSync, rmdirSync, statSync, symlinkSync, unlinkSync, utimesSync } from 'node:fs'
9
10
  import { homedir } from 'node:os'
10
- import { basename, dirname, join, sep } from 'node:path'
11
+ import { basename, dirname, join, resolve, sep } from 'node:path'
11
12
 
12
13
  import { HarnessError } from '../core/errors'
13
14
  import type { Account } from './store'
@@ -104,53 +105,178 @@ const modified = (path: string): number => {
104
105
  }
105
106
  }
106
107
 
107
- /** Moves a real file or folder aside so the shared link can take its place. */
108
- function backUp(path: string): void {
109
- renameSync(path, join(dirname(path), `${basename(path)}.agentx-backup-${Math.floor(Date.now() / 1000)}`))
108
+ /** Moves a real file or folder aside so the shared link can take its place; returns where it went. */
109
+ function backUp(path: string): string {
110
+ const backup = join(dirname(path), `${basename(path)}.agentx-backup-${Math.floor(Date.now() / 1000)}`)
111
+ renameSync(path, backup)
112
+ return backup
110
113
  }
111
114
 
112
115
  /**
113
- * Links the account's shared entries to `_shared/pi/`. A real entry found in the account folder
114
- * (from before sharing, or a tool that replaced the link while saving) becomes the shared copy
115
- * when there is none or it is newer; otherwise it is kept as a backup.
116
+ * How one shared entry is linked into an account folder. Windows refuses a symbolic link to a
117
+ * user without the symlink privilege, so each kind has a fallback that needs none.
116
118
  */
117
- function linkShared(accountsDir: string, dir: string): void {
118
- const shared = ensureSharedDir(accountsDir)
119
- for (const [name, isDir] of SHARED_ENTRIES) {
120
- const local = join(dir, name)
121
- const target = join(shared, name)
122
- let meta: ReturnType<typeof lstatSync> | null = null
123
- try {
124
- meta = lstatSync(local)
125
- } catch {
126
- meta = null
127
- }
128
- if (meta?.isSymbolicLink()) {
129
- if (readlinkSync(local) === target) {
130
- if (isDir) mkdirSync(target, { recursive: true })
131
- continue
119
+ export interface EntryLinker {
120
+ /** A symbolic link to a file, or a junction to a folder (junctions need no privilege on Windows). */
121
+ symlink(target: string, path: string, isDir: boolean): void
122
+ /** A hard link to a file on the same volume. */
123
+ hardLink(target: string, path: string): void
124
+ }
125
+
126
+ const nodeLinker: EntryLinker = {
127
+ symlink: (target, path, isDir) => symlinkSync(resolve(target), path, isDir ? 'junction' : 'file'),
128
+ hardLink: (target, path) => linkSync(target, path)
129
+ }
130
+
131
+ const codeOf = (error: unknown): string => (error as NodeJS.ErrnoException).code ?? String(error)
132
+ const PRIVILEGE_CODES = ['EPERM', 'EACCES']
133
+
134
+ function createLink(target: string, path: string, isDir: boolean, linker: EntryLinker): void {
135
+ try {
136
+ linker.symlink(target, path, isDir)
137
+ } catch (error) {
138
+ // A file can still be hard-linked without the privilege; a folder has no such fallback.
139
+ if (isDir || !PRIVILEGE_CODES.includes(codeOf(error))) throw error
140
+ linker.hardLink(target, path)
141
+ }
142
+ }
143
+
144
+ const isLink = (path: string): boolean => {
145
+ try {
146
+ return lstatSync(path).isSymbolicLink()
147
+ } catch {
148
+ return false
149
+ }
150
+ }
151
+
152
+ // Windows reports some link targets with the `\\?\` prefix.
153
+ const LONG_PREFIX = /^\\\\\?\\/
154
+ function samePath(a: string, b: string): boolean {
155
+ const normal = (path: string): string => {
156
+ const full = resolve(path.replace(LONG_PREFIX, ''))
157
+ return process.platform === 'win32' ? full.toLowerCase() : full
158
+ }
159
+ return normal(a) === normal(b)
160
+ }
161
+
162
+ /** A hard link is the same file on disk as its target, so it has the same device and inode. */
163
+ function sameFile(a: string, b: string): boolean {
164
+ try {
165
+ const left = lstatSync(a)
166
+ const right = lstatSync(b)
167
+ return left.ino !== 0 && left.dev === right.dev && left.ino === right.ino
168
+ } catch {
169
+ return false
170
+ }
171
+ }
172
+
173
+ function isLinkTo(path: string, target: string, isDir: boolean): boolean {
174
+ try {
175
+ const meta = lstatSync(path)
176
+ if (meta.isSymbolicLink()) return samePath(readlinkSync(path), target)
177
+ return !isDir && meta.isFile() && sameFile(path, target)
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+
183
+ /** Removes a link without touching what it points to. A Windows junction needs `rmdir`. */
184
+ function removeLink(path: string, isDir: boolean): void {
185
+ try {
186
+ unlinkSync(path)
187
+ } catch (error) {
188
+ if (!isDir) throw error
189
+ rmdirSync(path)
190
+ }
191
+ }
192
+
193
+ /**
194
+ * A file the account does not have, for an entry whose link was refused: a copy of the shared one
195
+ * (with its time kept, so the newest-wins rule does not move it), so the account's settings load.
196
+ */
197
+ function keepCopy(target: string, path: string, isDir: boolean): void {
198
+ if (isDir || present(path) || !present(target)) return
199
+ try {
200
+ copyFileSync(target, path)
201
+ const { atime, mtime } = statSync(target)
202
+ utimesSync(path, atime, mtime)
203
+ } catch {
204
+ // The account simply keeps no copy; its sign-in and its own files are not affected.
205
+ }
206
+ }
207
+
208
+ /** What happened to one shared entry: linked to the shared copy, or left as the account's own. */
209
+ export interface SharedEntry {
210
+ name: string
211
+ state: 'linked' | 'unshared'
212
+ /** Why the link was refused, when it was. */
213
+ reason?: string
214
+ }
215
+
216
+ /**
217
+ * Links one entry to its shared copy. A real entry found in the account folder (from before
218
+ * sharing, or a tool that replaced the link while saving) becomes the shared copy when there is
219
+ * none or it is newer; otherwise it is kept as a backup. If the link is then refused, every move
220
+ * is undone, so the account keeps what it had.
221
+ */
222
+ function linkOne(name: string, target: string, path: string, isDir: boolean, linker: EntryLinker): SharedEntry {
223
+ if (isLinkTo(path, target, isDir)) {
224
+ if (isDir) mkdirSync(target, { recursive: true })
225
+ return { name, state: 'linked' }
226
+ }
227
+ const undo: (() => void)[] = []
228
+ try {
229
+ if (isLink(path)) {
230
+ const previous = readlinkSync(path)
231
+ removeLink(path, isDir)
232
+ undo.push(() => nodeLinker.symlink(previous, path, isDir))
233
+ } else if (present(path)) {
234
+ if (!present(target)) {
235
+ renameSync(path, target)
236
+ undo.push(() => renameSync(target, path))
237
+ } else if (!isDir && modified(path) > modified(target)) {
238
+ const backup = backUp(target)
239
+ undo.push(() => renameSync(backup, target))
240
+ renameSync(path, target)
241
+ undo.push(() => renameSync(target, path))
242
+ } else {
243
+ const backup = backUp(path)
244
+ undo.push(() => renameSync(backup, path))
132
245
  }
133
- unlinkSync(local)
134
- } else if (meta) {
135
- if (!present(target)) renameSync(local, target)
136
- else if (!isDir && modified(local) > modified(target)) {
137
- backUp(target)
138
- renameSync(local, target)
139
- } else backUp(local)
140
246
  }
141
247
  if (isDir) mkdirSync(target, { recursive: true })
142
- symlinkSync(target, local)
248
+ createLink(target, path, isDir, linker)
249
+ return { name, state: 'linked' }
250
+ } catch (error) {
251
+ for (const step of undo.reverse()) {
252
+ try {
253
+ step()
254
+ } catch {
255
+ // The entry stays where that step left it; nothing further can be restored safely.
256
+ }
257
+ }
258
+ keepCopy(target, path, isDir)
259
+ return { name, state: 'unshared', reason: codeOf(error) }
143
260
  }
144
261
  }
145
262
 
146
- /** Creates the account's runtime folder and links the shared entries into it. */
147
- export function ensureHome(accountsDir: string, account: Pick<Account, 'id'>): string {
263
+ /**
264
+ * Creates the account's runtime folder and links the shared entries into it. Never throws for a
265
+ * refused link: each entry is reported, and the account falls back to its own copy.
266
+ */
267
+ export function linkSharedEntries(accountsDir: string, account: Pick<Account, 'id'>, linker: EntryLinker = nodeLinker): { dir: string; entries: SharedEntry[] } {
148
268
  makePrivate(join(accountsDir, account.id))
149
269
  makePrivate(homeDir(accountsDir, account.id))
150
270
  const dir = configDir(accountsDir, account)
151
271
  makePrivate(dir)
152
- linkShared(accountsDir, dir)
153
- return dir
272
+ const shared = ensureSharedDir(accountsDir)
273
+ const entries = SHARED_ENTRIES.map(([name, isDir]) => linkOne(name, join(shared, name), join(dir, name), isDir, linker))
274
+ return { dir, entries }
275
+ }
276
+
277
+ /** Creates the account's runtime folder and links the shared entries into it. */
278
+ export function ensureHome(accountsDir: string, account: Pick<Account, 'id'>): string {
279
+ return linkSharedEntries(accountsDir, account).dir
154
280
  }
155
281
 
156
282
  const inside = (path: string, parent: string): boolean => path === parent || path.startsWith(parent.endsWith(sep) ? parent : `${parent}${sep}`)
@@ -166,16 +292,20 @@ export function assertIsolated(accountsDir: string, dir: string): void {
166
292
  }
167
293
  }
168
294
 
169
- /** Variables to set, or remove (`null`), when launching a session for this account. */
170
- export function launchEnv(accountsDir: string, account: Pick<Account, 'id'>, inherited: NodeJS.ProcessEnv = process.env): Record<string, string | null> {
171
- const dir = ensureHome(accountsDir, account)
172
- assertIsolated(accountsDir, dir)
295
+ function sessionEnv(dir: string, inherited: NodeJS.ProcessEnv): Record<string, string | null> {
173
296
  const env: Record<string, string | null> = {}
174
297
  for (const name of inheritedCredentialVars(inherited)) env[name] = null
175
298
  env.PI_CODING_AGENT_DIR = dir
176
299
  return env
177
300
  }
178
301
 
302
+ /** Variables to set, or remove (`null`), when launching a session for this account. */
303
+ export function launchEnv(accountsDir: string, account: Pick<Account, 'id'>, inherited: NodeJS.ProcessEnv = process.env): Record<string, string | null> {
304
+ const dir = ensureHome(accountsDir, account)
305
+ assertIsolated(accountsDir, dir)
306
+ return sessionEnv(dir, inherited)
307
+ }
308
+
179
309
  /** What a session needs to run for an account. */
180
310
  export interface AccountContext {
181
311
  id: string
@@ -185,10 +315,14 @@ export interface AccountContext {
185
315
  env: Record<string, string | null>
186
316
  /** The account's own agent folder. */
187
317
  configDir: string
318
+ /** Which shared entries are linked, and which the account keeps as its own. */
319
+ sharing: SharedEntry[]
188
320
  }
189
321
 
190
322
  export function accountContext(accountsDir: string, account: Account): AccountContext {
191
- return { id: account.id, provider: 'pi', label: account.label, env: launchEnv(accountsDir, account), configDir: configDir(accountsDir, account) }
323
+ const { dir, entries } = linkSharedEntries(accountsDir, account)
324
+ assertIsolated(accountsDir, dir)
325
+ return { id: account.id, provider: 'pi', label: account.label, env: sessionEnv(dir, process.env), configDir: dir, sharing: entries }
192
326
  }
193
327
 
194
328
  /** The process environment for a `pi` process launched on behalf of this account. */
@@ -50,11 +50,11 @@ export async function resolve(harness: Harness, params: ListParams & { tools?: r
50
50
  }
51
51
  }
52
52
 
53
- export function toolIntersection(parent: CapabilitySet, assignee: CapabilitySet, taskTools: readonly string[] = []): CapabilitySet {
54
- const permitted = new Set(taskTools.length ? taskTools : assignee.tools)
53
+ export function toolIntersection(parent: CapabilitySet, assignee: CapabilitySet, taskTools?: readonly string[]): CapabilitySet {
54
+ const permitted = taskTools === undefined ? null : new Set(taskTools)
55
55
  return {
56
56
  version: 1,
57
- tools: parent.tools.filter((tool) => assignee.tools.includes(tool) && permitted.has(tool)),
57
+ tools: parent.tools.filter((tool) => assignee.tools.includes(tool) && (!permitted || permitted.has(tool))),
58
58
  connectors: parent.connectors.filter((id) => assignee.connectors.includes(id)),
59
59
  contributions: parent.contributions.filter((id) => assignee.contributions.includes(id))
60
60
  }
package/src/harness.ts CHANGED
@@ -15,6 +15,7 @@ import { readJsonOr } from './core/fs'
15
15
  import { closeIndex } from './core/index'
16
16
  import { projectsOrNone } from './core/projects'
17
17
  import { ConnectedApps } from './mcp/service'
18
+ import { migrateConnectionScopes } from './mcp/scopeMigration'
18
19
  import type { MaintainerFactory } from './memory/maintainer'
19
20
  import { Maintenance } from './memory/maintenance'
20
21
  import * as migrate from './memory/migrate'
@@ -179,7 +180,7 @@ export class Harness {
179
180
  policy: this.policy,
180
181
  broker: this.apps,
181
182
  plugins: this.plugins,
182
- resolveCapabilities: (caller, tools) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, tools }),
183
+ resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }),
183
184
  factory: options.sessionFactory
184
185
  })
185
186
  this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs })
@@ -221,13 +222,22 @@ export class Harness {
221
222
  void this.hooks.reload()
222
223
  for (const folder of ['registry/skills', 'registry/prompts', 'registry/rules', 'config', 'runs']) mkdirSync(join(root, folder), { recursive: true })
223
224
  let migrationError: string | null = null
225
+ let wikiMigrationError: string | null = null
224
226
  try {
225
227
  if (migrate.run(this.env, agentIds)) log(`started Wiki storage version ${migrate.WIKI_VERSION}; the earlier Wiki is kept as a backup in the data root`)
226
228
  } catch (error) {
227
229
  migrationError = error instanceof Error ? error.message : String(error)
230
+ wikiMigrationError = migrationError
228
231
  log('Wiki migration failed', migrationError)
229
232
  }
230
- if (!migrationError) {
233
+ try {
234
+ migrateConnectionScopes(root, this.stateDir)
235
+ } catch (error) {
236
+ const detail = error instanceof Error ? error.message : String(error)
237
+ migrationError = migrationError ? `${migrationError}; connector scope migration: ${detail}` : detail
238
+ log('connector scope migration failed', detail)
239
+ }
240
+ if (!wikiMigrationError) {
231
241
  try {
232
242
  store.ensureLayout(root, this.env.productName)
233
243
  // Every project has its fixed pages, and the generated indexes match the files.
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export { fileSecretStore, memorySecretStore, secretKey, type SecretStore } from
17
17
  export type { Account, AccountsOverview, Pool } from './accounts/store'
18
18
  export type { ModelOption } from './accounts/accounts'
19
19
  export type { Capability, CapabilityCatalogue, CapabilityKind, CapabilityScope, CapabilitySet } from './capabilities/types'
20
+ export type { RunCapabilitySnapshot } from './capabilities/runSnapshot'
20
21
 
21
22
  export type { CallerContext } from './memory/caller'
22
23
  export { dispatch as memoryCall } from './memory/dispatch'
@@ -61,7 +62,7 @@ export { HARNESS_PLUGINS } from './plugins/builtin'
61
62
  export { FIRST_PARTY_PLUGINS } from './plugins/firstParty'
62
63
  export type { Found, Resolver } from './plugins/discover'
63
64
  export type { Loading, Registered } from './plugins/plugins'
64
- export { CONTRIBUTION_KINDS, PLUGIN_SOURCES, type Contribution, type ContributionKind, type ContributionState, type Delivery, type Inventory, type InventoryItem, type Plugin, type PluginDescriptor, type PluginManage, type PluginSetting, type PluginSource } from './plugins/types'
65
+ export { CONTRIBUTION_KINDS, PLUGIN_SOURCES, type ConnectorTemplate, type Contribution, type ContributionKind, type ContributionState, type Delivery, type Inventory, type InventoryItem, type Plugin, type PluginDescriptor, type PluginManage, type PluginSetting, type PluginSource } from './plugins/types'
65
66
 
66
67
  export { methods } from './server/methods'
67
68
  export { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'