openvisio-agent 0.18.0 → 0.18.2
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/README.md +13 -2
- package/bin/cli.mjs +40 -0
- package/package.json +1 -1
- package/scripts/certify.mjs +30 -1
- package/src/events.mjs +65 -3
- package/src/mcp-http.mjs +85 -0
- package/src/memory.mjs +82 -0
- package/src/opencode-config.mjs +29 -0
- package/src/pr-push.mjs +112 -0
- package/src/watch.mjs +251 -110
package/README.md
CHANGED
|
@@ -58,6 +58,8 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
|
|
|
58
58
|
|
|
59
59
|
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session uses the backend's actual tools (`list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity`) to recover assigned tasks and recent mention activity missed while offline. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
60
60
|
|
|
61
|
+
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only `get_marching_orders`, `poll_inbox`, or `get_resource` tools.
|
|
62
|
+
|
|
61
63
|
Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
|
|
62
64
|
|
|
63
65
|
Claude uses Haiku for routine coordination and Sonnet for repository work. Codex uses `gpt-5.6-sol` for every cycle, including messages, mentions, triage, board movement, MCP calls, and coding. This intentionally favors reliability and consistent tool use over the cheaper Codex tiers.
|
|
@@ -69,7 +71,16 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
|
|
|
69
71
|
openvisio-agent stop --name ada # stop service + every ada watcher
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
With `--workdir`, the agent gets file +
|
|
74
|
+
With `--workdir`, the agent gets file + shell tools scoped to the workspace and works on an `agent/*` branch. For a codebase linked to OpenVisio, Codex first uses the authenticated MCP branch, commit, and pull-request tools. That creates the PR through the existing integration and avoids a local `git push`.
|
|
75
|
+
|
|
76
|
+
When those linked-codebase tools are unavailable, a pull request still requires exporting the local branch to the private Git remote. The constrained fallback uses an explicit, one-time authorization per repository:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cd /path/to/private-repo
|
|
80
|
+
openvisio-agent authorize-pr-push
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That command installs a narrow Codex rule for `openvisio-agent push-pr-branch` and records the repository's exact root and `origin`. The helper accepts no arguments and can only push `HEAD` to the same `agent/*` branch on that authorized origin. It disables repository hooks and rejects main/master, other branch namespaces, changed remotes, force pushes, local/file remotes, and credential-bearing URLs. Revoke it from the repository with `openvisio-agent revoke-pr-push`.
|
|
73
84
|
|
|
74
85
|
`--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
|
|
75
86
|
|
|
@@ -79,7 +90,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
|
|
|
79
90
|
|
|
80
91
|
- **No opaque script.** You run a named, versioned npm package you can read here and on [npmjs.com](https://www.npmjs.com/package/openvisio-agent).
|
|
81
92
|
- **Single-use code.** The `ovs_` code is exchanged once for a key; a leaked code is already spent.
|
|
82
|
-
- **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools.
|
|
93
|
+
- **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools. Codex private-repository pushes require an explicit per-repository opt-in and go through a no-argument `agent/*`-only helper; merges and protected-branch pushes remain unavailable.
|
|
83
94
|
- **Local secrets.** Your agent key lives in `~/.openvisio/` with `600` permissions — never printed, never committed.
|
|
84
95
|
|
|
85
96
|
## Requirements
|
package/bin/cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
|
|
17
17
|
import { runWatch, installService, stopWatchers } from '../src/watch.mjs'
|
|
18
|
+
import { authorizePrPush, pushPrBranch, revokePrPush } from '../src/pr-push.mjs'
|
|
18
19
|
|
|
19
20
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
20
21
|
const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
|
|
@@ -28,6 +29,9 @@ Usage:
|
|
|
28
29
|
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|codex|opencode]
|
|
29
30
|
openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
|
|
30
31
|
openvisio-agent stop --name <agent>
|
|
32
|
+
openvisio-agent authorize-pr-push [--repo <dir>]
|
|
33
|
+
openvisio-agent push-pr-branch
|
|
34
|
+
openvisio-agent revoke-pr-push [--repo <dir>]
|
|
31
35
|
openvisio-agent --help | --version
|
|
32
36
|
|
|
33
37
|
connect
|
|
@@ -78,6 +82,19 @@ stop
|
|
|
78
82
|
remaining watcher with that exact --name and clears its stale lock. Use this
|
|
79
83
|
instead of killing changing PIDs: openvisio-agent stop --name Alex
|
|
80
84
|
|
|
85
|
+
authorize-pr-push
|
|
86
|
+
One-time, explicit authorization for the current private repository. Installs a
|
|
87
|
+
narrow Codex command rule and records the exact repository root + origin. The
|
|
88
|
+
permitted helper can only push the current agent/* branch and cannot force-push,
|
|
89
|
+
choose another remote/ref, push a protected branch, or merge.
|
|
90
|
+
|
|
91
|
+
push-pr-branch
|
|
92
|
+
Pushes HEAD to the same agent/* branch on an origin previously authorized with
|
|
93
|
+
authorize-pr-push. Intended for Codex work cycles; accepts no arguments.
|
|
94
|
+
|
|
95
|
+
revoke-pr-push
|
|
96
|
+
Removes the current repository from the helper's authorization list.
|
|
97
|
+
|
|
81
98
|
Docs: https://www.npmjs.com/package/openvisio-agent`
|
|
82
99
|
|
|
83
100
|
// Register the `openvisio-team` MCP at USER (global) scope so it's available in
|
|
@@ -291,6 +308,29 @@ async function main() {
|
|
|
291
308
|
if (!name) fail('Missing agent name.\n Usage: openvisio-agent stop --name <agent>')
|
|
292
309
|
return stopWatchers({ slug: slugify(name) })
|
|
293
310
|
}
|
|
311
|
+
if (cmd === 'authorize-pr-push') {
|
|
312
|
+
const cwd = String(rest.flags.repo || rest.positional[0] || process.cwd())
|
|
313
|
+
const result = authorizePrPush({ cwd })
|
|
314
|
+
ok('Authorized constrained PR-branch pushes for this repository.')
|
|
315
|
+
info(`Repository: ${result.root}`)
|
|
316
|
+
info(`Origin: ${result.remote}`)
|
|
317
|
+
info('Codex can now run: openvisio-agent push-pr-branch')
|
|
318
|
+
info('This does not allow pushes to main/master, force pushes, arbitrary remotes, or merges.')
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (cmd === 'push-pr-branch') {
|
|
322
|
+
if (rest.positional.length || Object.keys(rest.flags).length) fail('push-pr-branch accepts no arguments. It uses the current repository, origin, and agent/* branch.')
|
|
323
|
+
const result = pushPrBranch()
|
|
324
|
+
ok(`Pushed ${result.branch} to its matching origin branch.`)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (cmd === 'revoke-pr-push') {
|
|
328
|
+
const cwd = String(rest.flags.repo || rest.positional[0] || process.cwd())
|
|
329
|
+
const result = revokePrPush({ cwd })
|
|
330
|
+
if (result.revoked) ok(`Revoked constrained PR pushes for ${result.root}.`)
|
|
331
|
+
else info(`No PR-push authorization was stored for ${result.root}.`)
|
|
332
|
+
return
|
|
333
|
+
}
|
|
294
334
|
fail(`Unknown command "${cmd}".\n\n${HELP}`)
|
|
295
335
|
}
|
|
296
336
|
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -21,9 +21,16 @@ run('package dry run', 'npm', ['pack', '--dry-run'])
|
|
|
21
21
|
run('diff whitespace check', 'git', ['diff', '--check'], repo)
|
|
22
22
|
|
|
23
23
|
const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
|
|
24
|
+
const events = readFileSync(join(root, 'src', 'events.mjs'), 'utf8')
|
|
25
|
+
const memory = readFileSync(join(root, 'src', 'memory.mjs'), 'utf8')
|
|
26
|
+
const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
|
|
27
|
+
const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
|
|
28
|
+
const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
|
|
29
|
+
const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
|
|
24
30
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
25
31
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
26
32
|
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
33
|
+
const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
|
|
27
34
|
const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
|
|
28
35
|
|
|
29
36
|
const assertions = [
|
|
@@ -31,13 +38,19 @@ const assertions = [
|
|
|
31
38
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
32
39
|
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
|
|
33
40
|
['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
|
|
34
|
-
['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes(
|
|
41
|
+
['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
|
|
35
42
|
['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
|
|
36
43
|
['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
|
|
37
44
|
['verified completion is persisted as one ticket comment', watcher.includes("callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
|
|
38
45
|
['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
|
|
39
46
|
['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
|
|
40
47
|
['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
|
|
48
|
+
['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
|
|
49
|
+
['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
|
|
50
|
+
['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && watcher.includes('disabled_tools = [')],
|
|
51
|
+
['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
|
|
52
|
+
['guarded replies stay independent while a lane is busy', watcher.includes('lane.deferred.push') && watcher.includes('lane.deferred.shift()')],
|
|
53
|
+
['BYO memory uses real ticket and thread identities', watcher.includes('createByoMemoryGraph') && watcher.includes('memory.context(memoryRefs)') && memory.includes('sameRef(r.projectId, refs.projectId)') && memory.includes('sameRef(r.threadId, refs.threadId)')],
|
|
41
54
|
['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
|
|
42
55
|
['work and reply activity targets are isolated', watcher.includes("laneStatusTargets = { work: new Set(), reply: new Set() }") && watcher.includes("emitLaneStatus('work', 'typing')") && watcher.includes("emitLaneStatus('reply', 'typing')")],
|
|
43
56
|
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
|
@@ -49,13 +62,29 @@ const assertions = [
|
|
|
49
62
|
['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
|
|
50
63
|
['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
|
|
51
64
|
['frontend consumes documented task comment events', taskHook.includes("'task:comment':") && taskHook.includes("'task:comment_updated':") && taskHook.includes("'task:comment_deleted':") && taskHook.includes("'task:comment_reacted':")],
|
|
65
|
+
['ticket comments load only on demand', !taskHook.includes('for (const task of tasks)') && taskHook.includes('loadedCommentIds.current.has(taskId)')],
|
|
66
|
+
['concurrent ticket comment loads share one request', taskHook.includes('commentRequests.current.get(requestKey)') && taskHook.includes('commentRequests.current.set(requestKey, request)')],
|
|
67
|
+
['backend ticket slugs render uppercase', liveTasks.includes("t.slug?.trim().toUpperCase()")],
|
|
52
68
|
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
53
69
|
['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
|
|
70
|
+
['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
|
|
71
|
+
['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
|
|
72
|
+
['OpenCode backend prompts forbid relay-only tools', watcher.includes('BACKEND MCP RULE') && watcher.includes('get_marching_orders, poll_inbox, and get_resource are relay-only')],
|
|
73
|
+
['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
|
|
54
74
|
['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
|
|
75
|
+
['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
|
|
76
|
+
['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
|
|
77
|
+
['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
|
|
78
|
+
['backend introduction is watcher-owned for every runtime', watcher.includes('void announceIntroduction().then((delivered)')],
|
|
55
79
|
['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
|
|
56
80
|
['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
|
|
57
81
|
['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
|
|
58
82
|
['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
83
|
+
['repository push authorization automatically resumes the paused ticket', watcher.includes('blockedTaskRepos: [...blockedTaskRepos]') && watcher.includes('repositoryHasPrPushAuthorization')],
|
|
84
|
+
['Codex prefers linked-codebase MCP PR delivery', watcher.includes('CODEX PR DELIVERY') && watcher.includes('create_codebase_branch/create_codebase_commit/create_pull_request') && events.includes('const codebaseMutation')],
|
|
85
|
+
['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', 'origin', destination")],
|
|
86
|
+
['PR push helper rejects protected/alternate/force targets by construction', prPush.includes("/^agent\\/") && prPush.includes('entry?.root === root && entry?.remote === remote') && prPush.includes('accepts no force, remote, or ref args')],
|
|
87
|
+
['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
|
|
59
88
|
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
|
|
60
89
|
['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
|
|
61
90
|
['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
|
package/src/events.mjs
CHANGED
|
@@ -89,14 +89,17 @@ export function opencodeEventEvidence(event) {
|
|
|
89
89
|
const status = String(state.status ?? part.status ?? '').toLowerCase()
|
|
90
90
|
const failed = /error|failed|denied|rejected/.test(status) || state.error != null || part.error != null
|
|
91
91
|
const completed = !failed && (!status || /completed|success|succeeded|ok/.test(status))
|
|
92
|
+
const rawToolError = state.error ?? part.error
|
|
93
|
+
const toolError = rawToolError == null ? '' : (typeof rawToolError === 'string' ? rawToolError : JSON.stringify(rawToolError))
|
|
92
94
|
const input = state.input && typeof state.input === 'object' ? state.input : (part.input && typeof part.input === 'object' ? part.input : {})
|
|
93
95
|
const command = String(input.command ?? input.cmd ?? '')
|
|
94
96
|
|
|
95
97
|
const lowerTool = tool.toLowerCase()
|
|
96
98
|
const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(tool)
|
|
97
99
|
const bareTool = lowerTool.replace(/[-.]/g, '_')
|
|
98
|
-
const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity)$/
|
|
100
|
+
const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity|list_codebases|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
|
|
99
101
|
const mcpTool = (prefixed?.[1] ? prefixed[1].replace(/[-.]/g, '_') : (knownMcp.test(bareTool) ? bareTool : '')).toLowerCase()
|
|
102
|
+
const codebaseMutation = /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool)
|
|
100
103
|
const mutationTool = /^(?:edit|write|patch|apply_patch|multiedit|multi_edit)$/i.test(tool)
|
|
101
104
|
const bashTool = /^(?:bash|shell|terminal|exec|command)$/i.test(tool)
|
|
102
105
|
const commandMutation = /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(command)
|
|
@@ -106,8 +109,9 @@ export function opencodeEventEvidence(event) {
|
|
|
106
109
|
...(mcpTool ? { mcpTool } : {}),
|
|
107
110
|
failed,
|
|
108
111
|
completed,
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
...(failed && toolError ? { toolError: toolError.replace(/\s+/g, ' ').slice(0, 500) } : {}),
|
|
113
|
+
didCode: completed && (mutationTool || bashTool || codebaseMutation),
|
|
114
|
+
didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
|
|
111
115
|
didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
|
|
112
116
|
didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
|
|
113
117
|
didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
|
|
@@ -131,6 +135,16 @@ export function agentStateRequest(backend, channelId, state, apiKey, identifier)
|
|
|
131
135
|
|
|
132
136
|
export function codexPolicyBlock(value) {
|
|
133
137
|
const text = String(value || '')
|
|
138
|
+
if (/OPENVISIO_PR_PUSH_AUTH_REQUIRED/i.test(text)) {
|
|
139
|
+
const root = /From\s+([^\n,]+),\s*run:\s*openvisio-agent authorize-pr-push/i.exec(text)?.[1]?.trim() || ''
|
|
140
|
+
return {
|
|
141
|
+
kind: 'pr-push-authorization-required',
|
|
142
|
+
command: 'openvisio-agent push-pr-branch',
|
|
143
|
+
reason: 'The repository has not received the one-time constrained PR-branch authorization.',
|
|
144
|
+
root,
|
|
145
|
+
setupCommand: 'openvisio-agent authorize-pr-push',
|
|
146
|
+
}
|
|
147
|
+
}
|
|
134
148
|
if (!/rejected due to unacceptable risk|action was rejected due to unacceptable risk|explicitly approves? the action/i.test(text)) return null
|
|
135
149
|
const command = /exec_command failed for [`']([^`']+)[`']/.exec(text)?.[1] || ''
|
|
136
150
|
const reasonTail = text.split(/Reason:\s*/i)[1] || ''
|
|
@@ -172,6 +186,54 @@ export function mentionDedupeKeys(message, channelId) {
|
|
|
172
186
|
}
|
|
173
187
|
}
|
|
174
188
|
|
|
189
|
+
export function normalizeRenderedMessageText(value) {
|
|
190
|
+
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Extract only messages visibly authored by this agent from the backend's live
|
|
194
|
+
// thread response. REST and WebSocket payloads use several sender shapes, so this
|
|
195
|
+
// mirrors the frontend normalizer instead of trusting one field name.
|
|
196
|
+
export function renderedAgentMessages(value, identity = {}) {
|
|
197
|
+
const rows = []
|
|
198
|
+
const seen = new Set()
|
|
199
|
+
const visit = (node, depth = 0) => {
|
|
200
|
+
if (depth > 6 || node == null) return
|
|
201
|
+
if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return }
|
|
202
|
+
if (typeof node !== 'object') return
|
|
203
|
+
const row = node
|
|
204
|
+
const content = row.content ?? row.body ?? row.text ?? (typeof row.message === 'string' ? row.message : undefined)
|
|
205
|
+
const rowId = row.id ?? row.message_id ?? row.messageId
|
|
206
|
+
if (content != null && rowId != null) {
|
|
207
|
+
const sender = [row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
|
|
208
|
+
const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
|
|
209
|
+
const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
|
|
210
|
+
const senderLooksLikeAgent = /agent|bot/.test(senderKind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
|
|
211
|
+
const agent = expanded || (senderLooksLikeAgent ? sender : {})
|
|
212
|
+
const agentId = Number(agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId ?? NaN)
|
|
213
|
+
const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
|
|
214
|
+
const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
|
|
215
|
+
const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
|
|
216
|
+
const isSelf = (Number.isFinite(Number(identity.id)) && agentId === Number(identity.id)) || aliases.has(identifier.toLowerCase()) || (!!expanded && aliases.has(name.toLowerCase()))
|
|
217
|
+
if (isSelf) {
|
|
218
|
+
const key = String(rowId)
|
|
219
|
+
if (!seen.has(key)) {
|
|
220
|
+
seen.add(key)
|
|
221
|
+
rows.push({
|
|
222
|
+
id: rowId,
|
|
223
|
+
parentId: row.parent_id ?? row.parentId ?? row.thread_id ?? null,
|
|
224
|
+
content: String(content),
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const key of ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message']) {
|
|
230
|
+
if (row[key] && typeof row[key] === 'object') visit(row[key], depth + 1)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
visit(value)
|
|
234
|
+
return rows
|
|
235
|
+
}
|
|
236
|
+
|
|
175
237
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
176
238
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
177
239
|
// model starts, while keeping explicitly shared requests addressed to both.
|
package/src/mcp-http.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const parsePayload = async (res) => {
|
|
2
|
+
const body = await res.text()
|
|
3
|
+
const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
|
|
4
|
+
try { return JSON.parse(data || body || '{}') }
|
|
5
|
+
catch { throw new Error('MCP returned a non-JSON response') }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Streamable HTTP permits both stateful servers (initialize returns
|
|
9
|
+
// Mcp-Session-Id) and stateless servers (no session header). The backend agent
|
|
10
|
+
// MCP is deployed in both forms, so absence of a session id is a transport mode,
|
|
11
|
+
// not an initialization failure.
|
|
12
|
+
export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fetchImpl = fetch, log = () => {} }) {
|
|
13
|
+
let initialized = false
|
|
14
|
+
let sessionId = ''
|
|
15
|
+
let rpcId = 0
|
|
16
|
+
let initializePromise = null
|
|
17
|
+
|
|
18
|
+
const post = (message, withSession = true) => fetchImpl(url, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
'content-type': 'application/json',
|
|
22
|
+
accept: 'application/json, text/event-stream',
|
|
23
|
+
'x-agent-api-key': apiKey,
|
|
24
|
+
'x-agent-identifier': identifier,
|
|
25
|
+
...(withSession && sessionId ? { 'mcp-session-id': sessionId } : {}),
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify(message),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const reset = () => { initialized = false; sessionId = '' }
|
|
31
|
+
|
|
32
|
+
const initialize = () => {
|
|
33
|
+
if (initialized) return Promise.resolve()
|
|
34
|
+
if (initializePromise) return initializePromise
|
|
35
|
+
initializePromise = (async () => {
|
|
36
|
+
const res = await post({
|
|
37
|
+
jsonrpc: '2.0',
|
|
38
|
+
id: ++rpcId,
|
|
39
|
+
method: 'initialize',
|
|
40
|
+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: clientVersion } },
|
|
41
|
+
}, false)
|
|
42
|
+
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
43
|
+
const payload = await parsePayload(res)
|
|
44
|
+
if (payload.error) throw new Error(`MCP initialize: ${payload.error.message || 'protocol error'}`)
|
|
45
|
+
sessionId = res.headers.get('mcp-session-id') || ''
|
|
46
|
+
|
|
47
|
+
const ready = await post({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
48
|
+
if (!ready.ok) { reset(); throw new Error('MCP initialized HTTP ' + ready.status) }
|
|
49
|
+
initialized = true
|
|
50
|
+
if (!sessionId) log('MCP initialized in stateless mode (no session id required)')
|
|
51
|
+
})().finally(() => { initializePromise = null })
|
|
52
|
+
return initializePromise
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const callTool = async (name, args = {}, retried = false) => {
|
|
56
|
+
await initialize()
|
|
57
|
+
const hadSession = !!sessionId
|
|
58
|
+
const res = await post({
|
|
59
|
+
jsonrpc: '2.0',
|
|
60
|
+
id: ++rpcId,
|
|
61
|
+
method: 'tools/call',
|
|
62
|
+
params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } },
|
|
63
|
+
})
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
// Only a stateful transport can have an expired session. A stateless 4xx
|
|
66
|
+
// belongs to the tool request itself and must not trigger an initialize loop.
|
|
67
|
+
if (hadSession && !retried && [400, 404, 409, 410].includes(res.status)) {
|
|
68
|
+
reset()
|
|
69
|
+
return callTool(name, args, true)
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`MCP ${name} HTTP ${res.status}`)
|
|
72
|
+
}
|
|
73
|
+
const payload = await parsePayload(res)
|
|
74
|
+
if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
|
|
75
|
+
const result = payload.result ?? payload
|
|
76
|
+
if (result?.isError) {
|
|
77
|
+
const detail = String(result.content?.find?.((item) => item?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
|
|
78
|
+
throw new Error(`MCP ${name}: ${detail}`)
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'
|
|
84
|
+
return { callTool, initialize, reset, mode }
|
|
85
|
+
}
|
package/src/memory.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const clean = (value, max = 320) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
5
|
+
const sameRef = (a, b) => a != null && b != null && String(a) === String(b)
|
|
6
|
+
|
|
7
|
+
// Small, deterministic memory graph for BYO watchers. Nodes are real events,
|
|
8
|
+
// tickets and deliveries; edges record what a reply answered or where a task was
|
|
9
|
+
// reported. It is intentionally not an LLM transcript or vector store: stable
|
|
10
|
+
// backend ids make exact recall cheaper and prevent old work from being replayed.
|
|
11
|
+
export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.now() }) {
|
|
12
|
+
let raw = {}
|
|
13
|
+
try { raw = JSON.parse(readFileSync(path, 'utf8')) } catch { /* first run */ }
|
|
14
|
+
const nodes = new Map((Array.isArray(raw.nodes) ? raw.nodes : []).filter((node) => node?.key).map((node) => [String(node.key), node]))
|
|
15
|
+
const edges = new Map((Array.isArray(raw.edges) ? raw.edges : []).filter((edge) => edge?.from && edge?.to && edge?.relation).map((edge) => [`${edge.from}|${edge.relation}|${edge.to}`, edge]))
|
|
16
|
+
|
|
17
|
+
const persist = () => {
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
20
|
+
writeFileSync(path, JSON.stringify({ version: 1, nodes: [...nodes.values()], edges: [...edges.values()] }, null, 2) + '\n', { mode: 0o600 })
|
|
21
|
+
} catch { /* memory is best-effort; live backend checks remain authoritative */ }
|
|
22
|
+
}
|
|
23
|
+
const trim = () => {
|
|
24
|
+
if (nodes.size <= maxNodes) return
|
|
25
|
+
const oldest = [...nodes.values()].sort((a, b) => Number(a.updatedAt || 0) - Number(b.updatedAt || 0)).slice(0, nodes.size - maxNodes)
|
|
26
|
+
const removed = new Set(oldest.map((node) => String(node.key)))
|
|
27
|
+
for (const key of removed) nodes.delete(key)
|
|
28
|
+
for (const [key, edge] of edges) if (removed.has(String(edge.from)) || removed.has(String(edge.to))) edges.delete(key)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const remember = ({ key, kind, state, summary, refs = {}, meta = {} }) => {
|
|
32
|
+
const id = String(key || '')
|
|
33
|
+
if (!id) return null
|
|
34
|
+
const previous = nodes.get(id)
|
|
35
|
+
const stamp = now()
|
|
36
|
+
const node = {
|
|
37
|
+
...(previous || { key: id, createdAt: stamp }),
|
|
38
|
+
kind: clean(kind, 40) || previous?.kind || 'event',
|
|
39
|
+
state: clean(state, 40) || previous?.state || 'observed',
|
|
40
|
+
summary: clean(summary) || previous?.summary || '',
|
|
41
|
+
refs: { ...(previous?.refs || {}), ...refs },
|
|
42
|
+
meta: { ...(previous?.meta || {}), ...meta },
|
|
43
|
+
updatedAt: stamp,
|
|
44
|
+
}
|
|
45
|
+
nodes.set(id, node); trim(); persist()
|
|
46
|
+
return node
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const connect = (from, to, relation) => {
|
|
50
|
+
const edge = { from: String(from || ''), to: String(to || ''), relation: clean(relation, 50), updatedAt: now() }
|
|
51
|
+
if (!edge.from || !edge.to || !edge.relation) return null
|
|
52
|
+
edges.set(`${edge.from}|${edge.relation}|${edge.to}`, edge); persist()
|
|
53
|
+
return edge
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const recall = (refs = {}, limit = 8) => {
|
|
57
|
+
const direct = [...nodes.values()].filter((node) => {
|
|
58
|
+
const r = node.refs || {}
|
|
59
|
+
return sameRef(r.channelId, refs.channelId) && (refs.threadId == null || sameRef(r.threadId, refs.threadId)) ||
|
|
60
|
+
sameRef(r.projectId, refs.projectId) && sameRef(r.ticketId, refs.ticketId)
|
|
61
|
+
})
|
|
62
|
+
const keys = new Set(direct.map((node) => String(node.key)))
|
|
63
|
+
for (const edge of edges.values()) {
|
|
64
|
+
if (keys.has(String(edge.from))) keys.add(String(edge.to))
|
|
65
|
+
if (keys.has(String(edge.to))) keys.add(String(edge.from))
|
|
66
|
+
}
|
|
67
|
+
return [...nodes.values()].filter((node) => keys.has(String(node.key))).sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0)).slice(0, limit)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const context = (refs = {}, limit = 8) => {
|
|
71
|
+
const items = recall(refs, limit)
|
|
72
|
+
if (!items.length) return ''
|
|
73
|
+
return ['RELEVANT VERIFIED MEMORY (do not repeat completed/delivered actions):', ...items.map((node) => `- ${node.kind} ${node.state}: ${node.summary || node.key}`)].join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const has = (key, state) => {
|
|
77
|
+
const node = nodes.get(String(key || ''))
|
|
78
|
+
return !!node && (state == null || node.state === state)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { remember, connect, recall, context, has, persist }
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { OV_DIR, slugify } from './lib.mjs'
|
|
3
|
+
|
|
4
|
+
export function opencodeRuntimeLayout({ cfgKey, workdir, baseDir = OV_DIR }) {
|
|
5
|
+
const agentKey = slugify(String(cfgKey || 'agent')) || 'agent'
|
|
6
|
+
const configDir = join(baseDir, 'opencode-' + agentKey)
|
|
7
|
+
return {
|
|
8
|
+
configDir,
|
|
9
|
+
configPath: join(configDir, 'opencode.json'),
|
|
10
|
+
workspace: workdir || configDir,
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildOpencodeConfig({ mcpUrl, mcpHeaders }) {
|
|
15
|
+
if (!mcpUrl) return null
|
|
16
|
+
return {
|
|
17
|
+
$schema: 'https://opencode.ai/config.json',
|
|
18
|
+
mcp: {
|
|
19
|
+
'openvisio-team': {
|
|
20
|
+
type: 'remote',
|
|
21
|
+
url: mcpUrl,
|
|
22
|
+
enabled: true,
|
|
23
|
+
oauth: false,
|
|
24
|
+
timeout: 15_000,
|
|
25
|
+
...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}),
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/pr-push.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
|
|
7
|
+
const DEFAULT_RULE_PATH = join(homedir(), '.codex', 'rules', 'openvisio-agent.rules')
|
|
8
|
+
|
|
9
|
+
const gitText = (cwd, args, spawn = spawnSync) => {
|
|
10
|
+
const result = spawn('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
11
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim())
|
|
12
|
+
return String(result.stdout || '').trim()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const safeRemote = (value) => {
|
|
16
|
+
const remote = String(value || '').trim()
|
|
17
|
+
if (/^https?:\/\//i.test(remote) || /^ssh:\/\//i.test(remote)) {
|
|
18
|
+
const parsed = new URL(remote)
|
|
19
|
+
if (parsed.username || parsed.password) throw new Error('Origin contains credentials in its URL. Move credentials to your Git credential manager before authorizing PR pushes.')
|
|
20
|
+
return remote
|
|
21
|
+
}
|
|
22
|
+
if (/^[^@\s]+@[^:\s]+:[^\s]+$/.test(remote)) return remote
|
|
23
|
+
throw new Error('Origin must be an HTTPS or SSH repository URL. Local paths and file:// remotes cannot be authorized.')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isSafeAgentBranch(value) {
|
|
27
|
+
const branch = String(value || '')
|
|
28
|
+
return /^agent\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch) &&
|
|
29
|
+
!branch.includes('..') && !branch.includes('//') && !branch.includes('@{') &&
|
|
30
|
+
!/[~^:?*\[\\]/.test(branch) && !/[/.]$/.test(branch)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function codexPrPushRule() {
|
|
34
|
+
return `# Generated by: openvisio-agent authorize-pr-push
|
|
35
|
+
# The helper enforces an exact pre-authorized repository + origin and only
|
|
36
|
+
# pushes the current agent/* branch. It accepts no force, remote, or ref args.
|
|
37
|
+
prefix_rule(
|
|
38
|
+
pattern = ["openvisio-agent", "push-pr-branch"],
|
|
39
|
+
decision = "allow",
|
|
40
|
+
justification = "The user explicitly authorized OpenVisio's constrained agent-branch PR push helper.",
|
|
41
|
+
match = ["openvisio-agent push-pr-branch"],
|
|
42
|
+
)
|
|
43
|
+
`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readAuthorizations(path) {
|
|
47
|
+
try {
|
|
48
|
+
const value = JSON.parse(readFileSync(path, 'utf8'))
|
|
49
|
+
return Array.isArray(value.authorizations) ? value.authorizations : []
|
|
50
|
+
} catch { return [] }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writePrivate(path, value) {
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
55
|
+
writeFileSync(path, value, { mode: 0o600 })
|
|
56
|
+
try { chmodSync(path, 0o600) } catch { /* Windows / best-effort */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function repositoryPushIdentity(cwd = process.cwd(), spawn = spawnSync) {
|
|
60
|
+
const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
|
|
61
|
+
const remote = safeRemote(gitText(root, ['remote', 'get-url', '--push', 'origin'], spawn))
|
|
62
|
+
const branch = gitText(root, ['branch', '--show-current'], spawn)
|
|
63
|
+
return { root, remote, branch }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validatePrPushAuthorization({ root, remote, branch, authorizations }) {
|
|
67
|
+
if (!isSafeAgentBranch(branch)) {
|
|
68
|
+
throw new Error(`Refusing to push branch "${branch || '(detached HEAD)'}". OpenVisio PR pushes require the current branch to match agent/*.`)
|
|
69
|
+
}
|
|
70
|
+
const allowed = (Array.isArray(authorizations) ? authorizations : []).some((entry) => entry?.root === root && entry?.remote === remote)
|
|
71
|
+
if (!allowed) {
|
|
72
|
+
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
|
+
}
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function repositoryHasPrPushAuthorization({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
|
|
78
|
+
try {
|
|
79
|
+
const { root, remote } = repositoryPushIdentity(cwd, spawn)
|
|
80
|
+
return readAuthorizations(authPath).some((entry) => entry?.root === root && entry?.remote === remote)
|
|
81
|
+
} catch { return false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
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)
|
|
86
|
+
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')
|
|
89
|
+
writePrivate(rulePath, codexPrPushRule())
|
|
90
|
+
return { root, remote, authPath, rulePath }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function revokePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
|
|
94
|
+
const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
|
|
95
|
+
const before = readAuthorizations(authPath)
|
|
96
|
+
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 }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function pushPrBranch({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
|
|
102
|
+
const identity = repositoryPushIdentity(cwd, spawn)
|
|
103
|
+
validatePrPushAuthorization({ ...identity, authorizations: readAuthorizations(authPath) })
|
|
104
|
+
const destination = `HEAD:refs/heads/${identity.branch}`
|
|
105
|
+
// The helper runs outside Codex's workspace sandbox after the user authorizes
|
|
106
|
+
// it. Disable repository hooks so an edited pre-push hook cannot widen this
|
|
107
|
+
// one operation into arbitrary host execution.
|
|
108
|
+
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' })
|
|
110
|
+
if (result.status !== 0) throw new Error(`git push failed with exit code ${result.status ?? 'unknown'}`)
|
|
111
|
+
return identity
|
|
112
|
+
}
|