openvisio-agent 0.19.10 → 0.19.11

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.
@@ -35,6 +35,7 @@ export function buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode = true }) {
35
35
  skill: 'deny',
36
36
  question: 'deny',
37
37
  'openvisio-team_*': 'allow',
38
+ 'openvisio-team-watcher_*': 'allow',
38
39
  }
39
40
  return {
40
41
  $schema: 'https://opencode.ai/config.json',
package/src/pr-push.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process'
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
2
+ import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
- import { dirname, join } from 'node:path'
4
+ import { dirname, join, relative, isAbsolute, resolve } from 'node:path'
5
+ import { writeJson } from './lib.mjs'
5
6
 
6
- const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
7
+ export const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
7
8
  const DEFAULT_RULE_PATH = join(homedir(), '.codex', 'rules', 'openvisio-agent.rules')
8
9
 
9
10
  const gitText = (cwd, args, spawn = spawnSync) => {
@@ -32,7 +33,7 @@ export function isSafeAgentBranch(value) {
32
33
 
33
34
  export function codexPrPushRule() {
34
35
  return `# Generated by: openvisio-agent authorize-pr-push
35
- # The helper enforces an exact pre-authorized repository + origin and only
36
+ # The helper enforces the configured workspace or exact repository + origin and only
36
37
  # pushes the current agent/* branch. It accepts no force, remote, or ref args.
37
38
  prefix_rule(
38
39
  pattern = ["openvisio-agent", "push-pr-branch"],
@@ -50,6 +51,41 @@ function readAuthorizations(path) {
50
51
  } catch { return [] }
51
52
  }
52
53
 
54
+ function readPolicy(path) {
55
+ try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return {} }
56
+ }
57
+
58
+ const within = (root, path) => {
59
+ if (!root || !path) return false
60
+ const rel = relative(root, path)
61
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')))
62
+ }
63
+
64
+ function policyAllows(identity, policy) {
65
+ const roots = [identity.root, identity.commonDir].filter(Boolean)
66
+ if ((policy.revoked || []).some((entry) => roots.includes(entry))) return false
67
+ return (policy.authorizations || []).some((entry) => entry.remote === identity.remote &&
68
+ (entry.root === identity.root || (entry.commonDir && entry.commonDir === identity.commonDir))) ||
69
+ (policy.workspaces || []).some((entry) => roots.some((root) => within(entry.root, root)))
70
+ }
71
+
72
+ // Connecting a coding workspace grants routine PR delivery once, including
73
+ // future clones and worktrees. The helper still controls branch and destination.
74
+ export function configurePrPublishing({ workspace, enabled = true, authPath = DEFAULT_AUTH_PATH, rulePath = DEFAULT_RULE_PATH } = {}) {
75
+ if (!workspace) return null
76
+ mkdirSync(workspace, { recursive: true })
77
+ const root = realpathSync(workspace)
78
+ if (dirname(root) === root || root === homedir()) throw new Error('Choose a dedicated coding workspace, not the home directory or filesystem root.')
79
+ const policy = readPolicy(authPath)
80
+ const workspaces = (policy.workspaces || []).filter((entry) => entry.root !== root)
81
+ if (enabled) workspaces.push({ root })
82
+ if (JSON.stringify(workspaces) !== JSON.stringify(policy.workspaces || [])) {
83
+ writeJson(authPath, { ...policy, version: 2, workspaces }, true)
84
+ }
85
+ if (enabled) writePrivate(rulePath, codexPrPushRule())
86
+ return { root, enabled }
87
+ }
88
+
53
89
  function writePrivate(path, value) {
54
90
  mkdirSync(dirname(path), { recursive: true })
55
91
  writeFileSync(path, value, { mode: 0o600 })
@@ -60,14 +96,15 @@ export function repositoryPushIdentity(cwd = process.cwd(), spawn = spawnSync) {
60
96
  const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
61
97
  const remote = safeRemote(gitText(root, ['remote', 'get-url', '--push', 'origin'], spawn))
62
98
  const branch = gitText(root, ['branch', '--show-current'], spawn)
63
- return { root, remote, branch }
99
+ const commonDir = realpathSync(resolve(root, gitText(root, ['rev-parse', '--git-common-dir'], spawn)))
100
+ return { root, commonDir, remote, branch }
64
101
  }
65
102
 
66
- export function validatePrPushAuthorization({ root, remote, branch, authorizations }) {
103
+ export function validatePrPushAuthorization({ root, commonDir, remote, branch, authorizations, workspaces = [], revoked = [] }) {
67
104
  if (!isSafeAgentBranch(branch)) {
68
105
  throw new Error(`Refusing to push branch "${branch || '(detached HEAD)'}". OpenVisio PR pushes require the current branch to match agent/*.`)
69
106
  }
70
- const allowed = (Array.isArray(authorizations) ? authorizations : []).some((entry) => entry?.root === root && entry?.remote === remote)
107
+ const allowed = policyAllows({ root, commonDir, remote }, { authorizations, workspaces, revoked })
71
108
  if (!allowed) {
72
109
  throw new Error(`OPENVISIO_PR_PUSH_AUTH_REQUIRED: This repository and its exact origin are not authorized. From ${root}, run: openvisio-agent authorize-pr-push`)
73
110
  }
@@ -76,37 +113,39 @@ export function validatePrPushAuthorization({ root, remote, branch, authorizatio
76
113
 
77
114
  export function repositoryHasPrPushAuthorization({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
78
115
  try {
79
- const { root, remote } = repositoryPushIdentity(cwd, spawn)
80
- return readAuthorizations(authPath).some((entry) => entry?.root === root && entry?.remote === remote)
116
+ return policyAllows(repositoryPushIdentity(cwd, spawn), readPolicy(authPath))
81
117
  } catch { return false }
82
118
  }
83
119
 
84
120
  export function authorizePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, rulePath = DEFAULT_RULE_PATH, spawn = spawnSync, now = () => new Date().toISOString() } = {}) {
85
- const { root, remote } = repositoryPushIdentity(cwd, spawn)
121
+ const { root, commonDir, remote } = repositoryPushIdentity(cwd, spawn)
122
+ const policy = readPolicy(authPath)
86
123
  const authorizations = readAuthorizations(authPath).filter((entry) => entry?.root !== root)
87
- authorizations.push({ root, remote, authorizedAt: now() })
88
- writePrivate(authPath, JSON.stringify({ version: 1, authorizations }, null, 2) + '\n')
124
+ authorizations.push({ root, commonDir, remote, authorizedAt: now() })
125
+ writeJson(authPath, { ...policy, version: 2, authorizations, revoked: (policy.revoked || []).filter((path) => path !== root && path !== commonDir) }, true)
89
126
  writePrivate(rulePath, codexPrPushRule())
90
127
  return { root, remote, authPath, rulePath }
91
128
  }
92
129
 
93
130
  export function revokePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
94
- const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
131
+ const { root, commonDir } = repositoryPushIdentity(cwd, spawn)
132
+ const policy = readPolicy(authPath)
95
133
  const before = readAuthorizations(authPath)
96
134
  const authorizations = before.filter((entry) => entry?.root !== root)
97
- if (existsSync(authPath)) writePrivate(authPath, JSON.stringify({ version: 1, authorizations }, null, 2) + '\n')
98
- return { root, revoked: authorizations.length !== before.length }
135
+ writeJson(authPath, { ...policy, version: 2, authorizations, revoked: [...new Set([...(policy.revoked || []), commonDir])] }, true)
136
+ return { root, revoked: true }
99
137
  }
100
138
 
101
139
  export function pushPrBranch({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
102
140
  const identity = repositoryPushIdentity(cwd, spawn)
103
- validatePrPushAuthorization({ ...identity, authorizations: readAuthorizations(authPath) })
141
+ validatePrPushAuthorization({ ...identity, ...readPolicy(authPath) })
104
142
  const destination = `HEAD:refs/heads/${identity.branch}`
105
143
  // The helper runs outside Codex's workspace sandbox after the user authorizes
106
144
  // it. Disable repository hooks so an edited pre-push hook cannot widen this
107
145
  // one operation into arbitrary host execution.
108
146
  const nullHooks = process.platform === 'win32' ? 'NUL' : '/dev/null'
109
- const result = spawn('git', ['-c', `core.hooksPath=${nullHooks}`, 'push', '-u', 'origin', destination], { cwd: identity.root, stdio: 'inherit' })
147
+ // Pin the verified URL: `git push origin` may fan out to multiple pushurls.
148
+ const result = spawn('git', ['-c', `core.hooksPath=${nullHooks}`, 'push', '-u', identity.remote, destination], { cwd: identity.root, stdio: 'inherit' })
110
149
  if (result.status !== 0) throw new Error(`git push failed with exit code ${result.status ?? 'unknown'}`)
111
150
  return identity
112
151
  }
@@ -0,0 +1,38 @@
1
+ // Bounded LRU of warm sessions, pinned while in use. Never evict an active
2
+ // session, and never share history between tickets or conversation threads.
3
+ export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now = Date.now }) {
4
+ const entries = new Map()
5
+ function prune() {
6
+ for (const [key, entry] of entries) {
7
+ if (!entry.busy && (now() - entry.usedAt >= idleMs || entries.size > capacity)) {
8
+ entry.runner.close?.()
9
+ entries.delete(key)
10
+ }
11
+ }
12
+ }
13
+ const timer = setInterval(prune, Math.min(idleMs, 30_000))
14
+ timer.unref?.()
15
+ return {
16
+ acquire(key, options) {
17
+ prune()
18
+ let entry = entries.get(key)
19
+ if (entry?.busy) throw new Error('Session already has an active cycle')
20
+ if (!entry) entry = { runner: create(options), usedAt: now(), busy: false }
21
+ entries.delete(key)
22
+ entry.busy = true
23
+ entries.set(key, entry)
24
+ prune()
25
+ return entry.runner
26
+ },
27
+ release(key) {
28
+ const entry = entries.get(key)
29
+ if (entry) { entry.busy = false; entry.usedAt = now() }
30
+ prune()
31
+ },
32
+ close() {
33
+ clearInterval(timer)
34
+ for (const entry of entries.values()) entry.runner.close?.()
35
+ entries.clear()
36
+ },
37
+ }
38
+ }
@@ -0,0 +1,24 @@
1
+ export function createTaskTypeLookup({ load, now = Date.now, ttlMs = 60_000 }) {
2
+ const cache = new Map(), pending = new Map()
3
+ return async (projectId) => {
4
+ const key = String(projectId)
5
+ const hit = cache.get(key)
6
+ if (hit && hit.expiresAt > now()) return hit.value
7
+ if (pending.has(key)) return pending.get(key)
8
+ const request = Promise.resolve().then(() => load(projectId)).then((data) => {
9
+ const types = data.types ?? data.task_types ?? data.taskTypes
10
+ if (!Array.isArray(types)) throw new Error('Cannot verify task columns')
11
+ const matching = (pattern) => new Set(types.filter((type) => pattern.test(String(type.name || ''))).map((type) => Number(type.id)))
12
+ const value = {
13
+ done: matching(/\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i),
14
+ review: matching(/\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i),
15
+ }
16
+ cache.delete(key)
17
+ cache.set(key, { value, expiresAt: now() + ttlMs })
18
+ while (cache.size > 100) cache.delete(cache.keys().next().value)
19
+ return value
20
+ }).finally(() => pending.delete(key))
21
+ pending.set(key, request)
22
+ return request
23
+ }
24
+ }