agy-mcp-bridge 0.1.31 → 0.1.33

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.
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { isQuotaError, isStrongQuotaError } from './quota.js';
4
- import { startProcess, terminateOwned } from '../antigravity/process.js';
4
+ import { captureOutput } from './probe.js';
5
5
  const CAPABILITIES = ['code', 'review', 'test', 'research', 'long-run'];
6
6
  const REQUIRED_FLAGS = ['--json', '--sandbox', '--cd', '--output-last-message', '--ephemeral', '--skip-git-repo-check'];
7
7
  const MAX_RESULT_BYTES = 1024 * 1024;
@@ -86,20 +86,6 @@ export function codexAdapter(command, options = {}) {
86
86
  export function codexAgent(command, status, options = {}) {
87
87
  return { adapter: codexAdapter(command, options), status, enabled: true, prefixArgs: command.prefixArgs };
88
88
  }
89
- async function capture(command, args, cwd, extraEnvPassthrough) {
90
- return new Promise((resolve, reject) => {
91
- const child = startProcess(command.executable, [...command.prefixArgs, ...args], cwd, { extraEnvPassthrough });
92
- let text = '';
93
- let exceeded = false;
94
- const timer = setTimeout(() => { exceeded = true; void terminateOwned(child).catch(reject); }, 10000);
95
- const append = (chunk) => { if (text.length < 32768)
96
- text += chunk.toString().slice(0, 32768 - text.length); };
97
- child.stdout.on('data', append);
98
- child.stderr.on('data', append);
99
- child.once('error', error => { clearTimeout(timer); reject(error); });
100
- child.once('close', code => { clearTimeout(timer); exceeded ? reject(new Error('Codex inspection timed out')) : resolve({ code, text }); });
101
- });
102
- }
103
89
  /** Non-interactive `codex exec` support is the only contract this adapter relies on. */
104
90
  export async function detectCodex(command, options = {}) {
105
91
  const cwd = options.cwd ?? process.cwd();
@@ -108,8 +94,8 @@ export async function detectCodex(command, options = {}) {
108
94
  id: 'codex', label: 'Codex', available, version, executable: command.source, capabilities: CAPABILITIES, warnings
109
95
  });
110
96
  try {
111
- const version = await capture(command, ['--version'], cwd, extra);
112
- const help = await capture(command, ['exec', '--help'], cwd, extra);
97
+ const version = await captureOutput('Codex', command, ['--version'], cwd, extra);
98
+ const help = await captureOutput('Codex', command, ['exec', '--help'], cwd, extra);
113
99
  const versionText = version.text.trim().split(/\r?\n/)[0] ?? '';
114
100
  const parsed = /codex-cli\s+([^\s]+)/i.exec(versionText)?.[1] ?? (versionText || null);
115
101
  const missing = REQUIRED_FLAGS.filter(flag => !new RegExp(`(?:^|\\s)${flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|,|$)`).test(help.text));
@@ -1,4 +1,4 @@
1
- import { startProcess, terminateOwned } from '../antigravity/process.js';
1
+ import { captureOutput } from './probe.js';
2
2
  import { isQuotaError } from './quota.js';
3
3
  export const CAPABILITIES = ['code', 'review', 'test', 'research', 'long-run'];
4
4
  // Verified against opencode v2.2.5: `opencode run` reads the message from stdin and, with `--format json`, writes one JSON event per line.
@@ -120,20 +120,6 @@ export function opencodeAdapter(command, options = {}) {
120
120
  export function opencodeAgent(command, status, options = {}) {
121
121
  return { adapter: opencodeAdapter(command, options), status, enabled: true, prefixArgs: command.prefixArgs };
122
122
  }
123
- function capture(command, args, cwd, extraEnvPassthrough) {
124
- return new Promise((resolve, reject) => {
125
- const child = startProcess(command.executable, [...command.prefixArgs, ...args], cwd, { extraEnvPassthrough });
126
- let text = '';
127
- let exceeded = false;
128
- const timer = setTimeout(() => { exceeded = true; void terminateOwned(child).catch(reject); }, 10000);
129
- const append = (chunk) => { if (text.length < 32768)
130
- text += chunk.toString().slice(0, 32768 - text.length); };
131
- child.stdout.on('data', append);
132
- child.stderr.on('data', append);
133
- child.once('error', error => { clearTimeout(timer); reject(error); });
134
- child.once('close', code => { clearTimeout(timer); exceeded ? reject(new Error('OpenCode inspection timed out')) : resolve({ code, text }); });
135
- });
136
- }
137
123
  /** Non-interactive `opencode run --format json` is the only contract this adapter relies on. */
138
124
  export async function detectOpenCode(command, opts = {}) {
139
125
  const cwd = opts.cwd ?? process.cwd();
@@ -142,8 +128,8 @@ export async function detectOpenCode(command, opts = {}) {
142
128
  id: 'opencode', label: 'OpenCode', available, version, executable: command.executable, capabilities: CAPABILITIES, warnings
143
129
  });
144
130
  try {
145
- const version = await capture(command, ['--version'], cwd, extra);
146
- const help = await capture(command, ['run', '--help'], cwd, extra);
131
+ const version = await captureOutput('OpenCode', command, ['--version'], cwd, extra);
132
+ const help = await captureOutput('OpenCode', command, ['run', '--help'], cwd, extra);
147
133
  const parsed = version.text.trim().split(/\r?\n/)[0]?.replace(/^opencode\s+/i, '').replace(/^v/i, '') || null;
148
134
  if (version.code !== 0)
149
135
  return status(false, null, ['opencode --version failed']);
@@ -0,0 +1,20 @@
1
+ import { startProcess, terminateOwned } from '../antigravity/process.js';
2
+ /**
3
+ * Runs an agent CLI once to read its output (`--version`, `<subcommand> --help`) while it is being inspected. Bounded in time (10 s) and in
4
+ * size (32 KiB of combined stdout and stderr), and the process is killed with its tree when the time runs out. `label` names the CLI in the error.
5
+ */
6
+ export function captureOutput(label, command, args, cwd, extraEnvPassthrough) {
7
+ return new Promise((resolve, reject) => {
8
+ const child = startProcess(command.executable, [...command.prefixArgs, ...args], cwd, { extraEnvPassthrough });
9
+ let text = '';
10
+ let exceeded = false;
11
+ const timer = setTimeout(() => { exceeded = true; void terminateOwned(child).catch(reject); }, 10000);
12
+ const append = (chunk) => { if (text.length < 32768)
13
+ text += chunk.toString().slice(0, 32768 - text.length); };
14
+ child.stdout.on('data', append);
15
+ child.stderr.on('data', append);
16
+ child.once('error', error => { clearTimeout(timer); reject(error); });
17
+ child.once('close', code => { clearTimeout(timer); exceeded ? reject(new Error(`${label} inspection timed out`)) : resolve({ code, text }); });
18
+ });
19
+ }
20
+ //# sourceMappingURL=probe.js.map
@@ -54,23 +54,30 @@ export async function createAgentRegistry(config, antigravity) {
54
54
  askForApproval: settings.codex_ask_for_approval
55
55
  }));
56
56
  const opencodeEnabled = enabled.includes('opencode');
57
- let opencodeStatus = {
57
+ const autoApproveWarning = (id) => `Agent "${id}" is configured to auto-approve permissions (opencode_auto_approve): tasks run with opencode --auto`;
58
+ // The OpenCode CLI is probed when the `opencode` agent is enabled, and also when an enabled opencode profile relies on it:
59
+ // OpenCode is opt-in, so a profile declared without `enabled` must not end up unavailable because the base agent is off.
60
+ const profileUsesOpencode = Object.entries(settings.profiles ?? {}).some(([id, profile]) => profile.adapter === 'opencode' && !profile.executable && (enabled.includes(id) || !settings.enabled));
61
+ let opencodeProbe = {
58
62
  id: 'opencode', label: 'OpenCode', available: false, version: null, executable: null,
59
63
  capabilities: CAPABILITIES, warnings: ['Disabled in the bridge config']
60
64
  };
61
65
  let opencodeCommand = { executable: 'opencode', prefixArgs: [], source: 'opencode' };
62
- if (opencodeEnabled) {
66
+ if (opencodeEnabled || profileUsesOpencode) {
63
67
  try {
64
68
  opencodeCommand = await resolveCommand(settings.opencode_executable ?? 'opencode');
65
- opencodeStatus = await detectOpenCode(opencodeCommand, { cwd: process.cwd(), extraEnvPassthrough: config.extra_env_passthrough });
69
+ opencodeProbe = await detectOpenCode(opencodeCommand, { cwd: process.cwd(), extraEnvPassthrough: config.extra_env_passthrough });
66
70
  }
67
71
  catch (error) {
68
- opencodeStatus = { ...opencodeStatus, warnings: [`OpenCode CLI not found: ${error instanceof Error ? error.message : 'unknown error'}`] };
72
+ opencodeProbe = { ...opencodeProbe, warnings: [`OpenCode CLI not found: ${error instanceof Error ? error.message : 'unknown error'}`] };
69
73
  }
70
74
  }
71
- const opencodeAutoWarning = 'Agent "opencode" is configured to auto-approve permissions (opencode_auto_approve): tasks run with opencode --auto';
75
+ // The base agent is only available when it was asked for; the probe above may exist just for a profile.
76
+ let opencodeStatus = opencodeEnabled
77
+ ? opencodeProbe
78
+ : { ...opencodeProbe, available: false, warnings: ['Disabled in the bridge config'] };
72
79
  if (opencodeEnabled && settings.opencode_auto_approve)
73
- opencodeStatus = { ...opencodeStatus, warnings: [...opencodeStatus.warnings, opencodeAutoWarning] };
80
+ opencodeStatus = { ...opencodeStatus, warnings: [...opencodeStatus.warnings, autoApproveWarning('opencode')] };
74
81
  agents.push(opencodeAgent(opencodeCommand, opencodeStatus, {
75
82
  model: settings.opencode_model,
76
83
  autoApprove: settings.opencode_auto_approve
@@ -79,7 +86,7 @@ export async function createAgentRegistry(config, antigravity) {
79
86
  for (const [profileId, profile] of Object.entries(settings.profiles)) {
80
87
  const isProfileEnabled = enabled.includes(profileId) || (!settings.enabled && true);
81
88
  const adapterType = profile.adapter;
82
- const label = profile.label ?? (adapterType === 'codex' ? `Codex (${profileId})` : `Antigravity (${profileId})`);
89
+ const label = profile.label ?? `${{ codex: 'Codex', antigravity: 'Antigravity', opencode: 'OpenCode' }[adapterType]} (${profileId})`;
83
90
  const profileWarnings = [];
84
91
  if (!isProfileEnabled)
85
92
  profileWarnings.push('Disabled in the bridge config');
@@ -148,8 +155,8 @@ export async function createAgentRegistry(config, antigravity) {
148
155
  else if (adapterType === 'opencode') {
149
156
  let cmd = opencodeCommand.executable;
150
157
  let prefixArgs = opencodeCommand.prefixArgs;
151
- let available = opencodeStatus.available;
152
- let version = opencodeStatus.version;
158
+ let available = opencodeProbe.available;
159
+ let version = opencodeProbe.version;
153
160
  if (profile.executable) {
154
161
  try {
155
162
  const resolved = await resolveCommand(profile.executable);
@@ -170,7 +177,7 @@ export async function createAgentRegistry(config, antigravity) {
170
177
  version: version || null, executable: cmd || null,
171
178
  capabilities: CAPABILITIES,
172
179
  warnings: isProfileEnabled
173
- ? [...opencodeStatus.warnings.filter(w => !w.includes('Disabled') && w !== opencodeAutoWarning), ...profileWarnings, ...(settings.opencode_auto_approve ? [opencodeAutoWarning.replace('"opencode"', `"${profileId}"`)] : [])]
180
+ ? [...(profile.executable ? [] : opencodeProbe.warnings.filter(w => !w.includes('Disabled'))), ...profileWarnings, ...(settings.opencode_auto_approve ? [autoApproveWarning(profileId)] : [])]
174
181
  : profileWarnings
175
182
  };
176
183
  agents.push({
package/dist/src/cli.js CHANGED
@@ -10,7 +10,7 @@ import { addSessionWorkspaces } from './config.js';
10
10
  import { recoverExecutionLock } from './tasks/execution-lock.js';
11
11
  import { renderSetup } from './setup.js';
12
12
  import { DEFAULT_TOOL_TIMEOUT_SECONDS, codexConfigPath, planCodexConfig, readCodexConfig, writeCodexConfig } from './codex-config.js';
13
- import { freebuffConfigPath, planFreebuffConfig, readFreebuffConfig, writeFreebuffConfig } from './freebuff-config.js';
13
+ import { freebuffConfigPath, isTransientInstall, planFreebuffConfig, readFreebuffConfig, writeFreebuffConfig } from './freebuff-config.js';
14
14
  import { readWait } from './wait-setting.js';
15
15
  import { configuredPort } from './http.js';
16
16
  import { offerSkillInstall, offerSkillUpdate } from './onboarding.js';
@@ -217,6 +217,8 @@ async function setup(args) {
217
217
  return;
218
218
  }
219
219
  const clients = target === 'both' ? ['codex', 'claude'] : [target];
220
+ if (target === 'freebuff' && isTransientInstall(paths.packageRoot))
221
+ console.error(`Note: this copy of ${BIN} runs from a temporary npx cache, so the stdio command printed below stops working once npm clears it. Install it globally (npm install -g ${pkg.name}) and print it again before using it.`);
220
222
  console.log(renderSetup({ clients, mcpUrl, shells: shell, bin: BIN, waitSeconds: readWait(paths.stateDirectory) ?? undefined, stdio: stdioCommand() }));
221
223
  }
222
224
  /** The command a stdio client must run for THIS installation: the interpreter and the packaged stdio entry, both absolute. */
@@ -225,6 +227,11 @@ export function stdioCommand() {
225
227
  }
226
228
  /** Shows exactly what would change in Freebuff's .agents/mcp.json and writes it only after a yes (or --yes). */
227
229
  async function applyFreebuffSetup(project, yes) {
230
+ if (isTransientInstall(paths.packageRoot)) {
231
+ console.error(`Not changed: this copy of ${BIN} runs from a temporary npx cache (${paths.packageRoot}), which npm can delete, so Freebuff's config would point at a path that stops working. Install it first: npm install -g ${pkg.name}, then run ${BIN} setup freebuff --apply again.`);
232
+ process.exitCode = 1;
233
+ return;
234
+ }
228
235
  if (project !== undefined) {
229
236
  const info = await stat(project).catch(() => null);
230
237
  if (!info?.isDirectory())
@@ -10,6 +10,11 @@ import path from 'node:path';
10
10
  * diff stays readable. The caller shows the plan and asks before anything is written, and a backup is made first.
11
11
  */
12
12
  export const SERVER_KEY = 'antigravity';
13
+ /**
14
+ * `npx` unpacks a package into a cache directory (`.../_npx/<hash>/node_modules/...`) that npm may delete at any time. An
15
+ * absolute path into it must not be written into a client's persistent config: it works today and breaks silently later.
16
+ */
17
+ export const isTransientInstall = (packageRoot) => /[\\/]_npx[\\/]/.test(packageRoot);
13
18
  /** `~/.agents/mcp.json`, or `<project>/.agents/mcp.json`: Freebuff reads the file nearest to where it runs. */
14
19
  export const freebuffConfigPath = (options = {}) => path.join(options.project ?? options.home ?? os.homedir(), '.agents', 'mcp.json');
15
20
  /** The indent the file already uses, so re-serializing it does not reformat the servers someone else wrote. */
@@ -54,13 +54,15 @@ const literal = (file) => `:(literal)${file}`;
54
54
  export async function applyWorktreeChanges(input) {
55
55
  try {
56
56
  const prefix = await prefixOf(input.toplevel, input.workspace);
57
- const scope = prefix ? [prefix] : ['.'];
57
+ const scope = prefix ? [literal(prefix)] : ['.'];
58
58
  const owns = input.owns.map(normalizeOwn);
59
59
  if (!owns.length)
60
60
  throw new Refusal('The task declared no owned paths, so there is nothing it was allowed to change');
61
+ // Renames are never detected: with them on, `--name-only` lists only the new name, so a file moved out of a protected path
62
+ // would be deleted from the workspace without the owned-paths check ever seeing it.
61
63
  // Staging is only a way to include new files in the diff: it touches the worktree's own index, never the workspace.
62
64
  await git(input.worktree, ['add', '-A', '--', ...scope]);
63
- const names = (await git(input.worktree, ['diff', '--cached', '--name-only', '-z', 'HEAD', '--', ...scope])).split('\0').filter(Boolean);
65
+ const names = (await git(input.worktree, ['diff', '--cached', '--no-renames', '--name-only', '-z', 'HEAD', '--', ...scope])).split('\0').filter(Boolean);
64
66
  if (!names.length)
65
67
  throw new Refusal('The worktree has no changes to apply');
66
68
  if (names.length > MAX_FILES)
@@ -70,10 +72,10 @@ export async function applyWorktreeChanges(input) {
70
72
  if (outside.length)
71
73
  throw new Refusal(`Files outside the task's owned paths were changed (${outside.slice(0, 5).join(', ')}); review them in the worktree`);
72
74
  // A symlink or a submodule pointer is more than text: it can point outside the workspace, so it is never applied from here.
73
- const raw = (await git(input.worktree, ['diff', '--cached', '--raw', '-z', 'HEAD', '--', ...scope])).split('\0');
75
+ const raw = (await git(input.worktree, ['diff', '--cached', '--no-renames', '--raw', '-z', 'HEAD', '--', ...scope])).split('\0');
74
76
  if (raw.some(field => /^:(?:12|16)0000 /.test(field) || /^:\d+ (?:12|16)0000 /.test(field)))
75
77
  throw new Refusal('The change includes a symlink or a submodule, which is not applied from here; review it in the worktree');
76
- const patch = await git(input.worktree, ['diff', '--cached', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', 'HEAD', '--', ...scope]);
78
+ const patch = await git(input.worktree, ['diff', '--cached', '--no-renames', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', 'HEAD', '--', ...scope]);
77
79
  if (Buffer.byteLength(patch) > MAX_PATCH_BYTES)
78
80
  throw new Refusal('The change is larger than the apply limit; review it in the worktree');
79
81
  // A file the owner is editing right now is theirs to merge by hand, even if the patch would happen to fit.
@@ -1,5 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
2
3
  import path from 'node:path';
4
+ import { inside } from '../security/workspace.js';
3
5
  import { redact } from '../security/redact.js';
4
6
  import { gitEnvironment } from './apply.js';
5
7
  /**
@@ -28,6 +30,10 @@ function capture(cwd, args) {
28
30
  export async function worktreeFileDiff(worktree, file) {
29
31
  if (!isRelativeFilePath(file))
30
32
  throw new Error('path must be a relative path inside the worktree');
33
+ // A link in the worktree (a junction on Windows) can lead anywhere; the diff reads files, so the real location must stay inside it.
34
+ const [base, real] = await Promise.all([realpath(worktree), realpath(path.join(worktree, file)).catch(() => null)]);
35
+ if (real !== null && !inside(base, real))
36
+ throw new Error('path leaves the worktree through a link, so it is not read');
31
37
  const literal = `:(literal)${file}`;
32
38
  // Tracked files diff against the base commit; a file the agent created is not in the index, so it is shown as all added lines.
33
39
  const tracked = (await capture(worktree, ['ls-files', '--error-unmatch', '-z', '--', literal])).code === 0;
@@ -0,0 +1,161 @@
1
+ # Hàng đợi duyệt cho hành động ngoài chính sách (Phase 4) — điều tra và thiết kế
2
+
3
+ **Trạng thái:** B1, chỉ tài liệu. Chưa có mã hàng đợi. Phần cài đặt (B2) chỉ bắt đầu khi chủ dự án đồng ý thiết kế ở mục 5–6 và trả lời các câu hỏi ở mục 8.
4
+
5
+ `docs/MULTI-AGENT-PLAN.md` (Phase 4) ghi "hàng đợi duyệt cho hành động ngoài chính sách". Điều chưa ai biết là **agent có dừng lại chờ duyệt giữa chừng được không**. Tài liệu này đo điều đó trên máy thật, rồi thiết kế đúng theo những gì đo được.
6
+
7
+ ## 1. Kết luận ngắn
8
+
9
+ 1. **Không CLI nào mà bridge dùng cho phép duyệt giữa chừng** trong chế độ không tương tác. Đo được trên `codex` và `opencode`; `agy` chưa đo lại được (mục 2.1).
10
+ - `codex exec`: không có sự kiện "xin quyền" nào, cả với `on-request` lẫn `never`. Hành động bị sandbox chặn chỉ hiện ra là một lệnh **thất bại** (`status: failed`, "Access ... is denied").
11
+ - `opencode run`: khi `permission` là `ask`, lời xin bị **tự từ chối ngay** (`auto-rejecting`) và run đi tiếp.
12
+ 2. Vì vậy MVP trung thực là **duyệt sau, không duyệt giữa chừng**: task kết thúc mà có hành động bị từ chối thì vào hàng đợi; chủ bridge chọn **"Cho phép và chạy lại"** (tạo task mới, `retry_of`, với quyền cao hơn *đúng cho một lần chạy đó*) hoặc **"Bỏ qua"**.
13
+ 3. **Bridge hôm nay chỉ nhìn thấy sự từ chối của `agy`** (`denied_actions`). `codex` chỉ được nhận diện khi item có status `declined`/`denied`, mà **chưa từng thấy** trong các lần đo; `opencode` báo `deniedActions: []` cố định dù có tín hiệu rõ ràng. Hàng đợi chỉ có nghĩa khi hai adapter này được dạy nhận ra sự từ chối (mục 4).
14
+ 4. Có cờ để nâng quyền **hẹp, một lần**: `codex --add-dir <dir>` (đã đo), `agy --mode accept-edits` (từ help, chưa đo), `opencode` qua `OPENCODE_CONFIG_CONTENT` theo từng lần chạy (cơ chế đã đo, tác dụng lên đúng quyền chưa đo).
15
+
16
+ ## 2. Đo đạc
17
+
18
+ Máy: Windows 11, `agy` 1.4.12, `codex-cli` 0.163.0 (đăng nhập ChatGPT, `~/.codex/config.toml` có `[windows] sandbox = "elevated"`), `opencode` v2.2.5. Mọi lần đo chạy trong một repo git **tạm** (`%TEMP%\approval-probe-*\ws`, một commit `a.txt`), thư mục "ngoài workspace" là thư mục tạm riêng; không chạy agent trên repo của người dùng. Hai thư mục tạm trong thư mục home (để nằm ngoài `%TEMP%`) đã được xóa sau mỗi lần.
19
+
20
+ ### 2.1 `agy` — chưa đo được ở 1.4.12
21
+
22
+ ```
23
+ agy --version → 1.4.12
24
+ agy --print "Reply with exactly: PROBE_OK" --output-format json
25
+ → Authentication required. Please visit the URL to log in: https://accounts.google.com/o/oauth2/auth?...
26
+ ```
27
+
28
+ `agy` chưa đăng nhập trên máy này và việc đăng nhập là của chủ máy, nên mọi điều dưới đây về `agy` là **cờ đọc từ `agy --help`** hoặc **bằng chứng đã ghi trước đây**, không phải số đo mới:
29
+
30
+ - Cờ (từ `agy --help`): `--mode accept-edits|auto|plan`, `--dangerously-skip-permissions` ("Auto-approve all tool permission requests without prompting"), `--sandbox` ("terminal restrictions"), `--output-format json`.
31
+ - Bằng chứng đã ghi (`docs/VERIFICATION.md`, mục 4, trên `agy` 1.4.x trước đây): yêu cầu tạo file trong scratch workspace → CLI trả `SUCCESS`/exit 0 nhưng `denied_actions` chứa `write_file`, response rỗng; bridge báo `failed`.
32
+ - Adapter hiện tại (`src/agents/antigravity.ts:39-44, 54-55`) đọc `envelope.denied_actions` (tối đa 100 mục, `action` và `display_name`) và biến task thành lỗi `Required tool permissions were denied; …`.
33
+
34
+ **Chưa biết:** hình dạng `denied_actions` cho hành động khác `write_file` (chạy lệnh, đọc ngoài workspace); mức `--mode` nào mở được loại hành động nào; hành vi trên 1.4.12.
35
+
36
+ ### 2.2 `codex` 0.163.0
37
+
38
+ Lệnh (cờ `--ask-for-approval` là cờ toàn cục, đứng **trước** `exec`):
39
+
40
+ ```
41
+ codex --ask-for-approval <on-request|never> exec --json --sandbox <mode> --skip-git-repo-check --ephemeral --cd <ws> --color never - (prompt qua stdin)
42
+ ```
43
+
44
+ | # | Chính sách | Hành động yêu cầu | Kết quả đo |
45
+ |---|---|---|---|
46
+ | c4 | `never`, `read-only` | ghi `inside.txt` trong workspace | `command_execution` **`failed`**, exit 1, `Access to the path '…\ws\inside.txt' is denied`. Codex thoát 0, `turn.completed`, agent tự kể lại lỗi |
47
+ | c2 | `on-request`, `workspace-write` | ghi `../outside/out.txt` (dưới `%TEMP%`) | **thành công**, exit 0 |
48
+ | c3 | `never`, `workspace-write` | như c2 | **thành công**, exit 0 |
49
+ | d1 | `on-request`, `workspace-write` | ghi file dưới thư mục home, **ngoài `%TEMP%`** | `failed`, exit 1, `Access … is denied`; file không tạo |
50
+ | d2 | `never`, `workspace-write` | như d1 | `failed`, exit 1, như d1 |
51
+ | e1 | `never`, `workspace-write --add-dir <A>` | ghi vào `A` và vào thư mục `B` không nêu tên | `A`: **thành công**; `B`: `failed`, exit 1, `Access … is denied` |
52
+ | c1 | `on-request`, `workspace-write` | ghi `inside.txt` trong workspace | cả hai lần thử: `failed`, exit −1, `Failed to create unified exec process: orchestrator_helper_exit_nonzero: setup helper exited with status Some(143)` — lỗi hạ tầng của sandbox Windows, **không** phải từ chối quyền; chỉ xảy ra ở lần này, c2/c3 chạy ngay sau đó bình thường |
53
+
54
+ Điều rút ra:
55
+
56
+ - **Không có sự kiện xin quyền trong JSONL.** Các loại sự kiện thấy được: `thread.started`, `turn.started`, `item.started|completed` (`agent_message`, `command_execution`, `error`), `turn.completed`. `on-request` và `never` cho cùng kết quả trong `exec`: lệnh bị chặn thất bại ngay, "Execution failures are immediately returned to the model" đúng như `--help`.
57
+ - **Không thấy `status: declined`/`denied` nào.** Điều kiện của adapter (`src/agents/codex.ts:62-63`) không bao giờ đúng với kiểu từ chối này, nên task kết thúc `completed` với câu trả lời của agent ("The write … was refused") và `denied_actions: []`. Bridge **không phân biệt được** "agent làm xong" với "agent bị chặn và kể lại".
58
+ - `workspace-write` **cho ghi vào thư mục tạm** (c2, c3) nhưng **không** cho ghi vào thư mục home ngoài workspace (d1, d2). Hai điều này cho thấy "ngoài workspace" của codex không đồng nghĩa với "ngoài `%TEMP%`" (suy ra từ hai nhóm kết quả, không phải từ tài liệu).
59
+ - `--add-dir <dir>` mở **đúng** thư mục nêu tên (e1) — đây là cách nâng quyền hẹp nhất cho "ghi ngoài workspace".
60
+ - `--sandbox danger-full-access` **không đo**: chạy một agent không có sandbox nằm ngoài phạm vi cuộc điều tra này. Chỉ biết từ `--help` rằng cờ đó tồn tại.
61
+ - Stderr của mọi lần chạy có `ERROR rmcp::transport::worker … http request failed … http://127.0.0.1:3000/mcp` (và `:8080`): codex nạp **MCP server khai báo trong `~/.codex/config.toml` của người dùng**, trong đó có chính bridge. Xem mục 7.
62
+
63
+ ### 2.3 `opencode` v2.2.5
64
+
65
+ Lệnh (cách bridge chạy; cấu hình quyền đưa qua biến môi trường để không đụng file cấu hình của người dùng):
66
+
67
+ ```
68
+ [OPENCODE_CONFIG_CONTENT='{"permission":{…}}'] opencode run --standalone --format json --model <free model> [--auto] (prompt qua stdin)
69
+ ```
70
+
71
+ | # | Cấu hình / cờ | Hành động yêu cầu | Kết quả đo |
72
+ |---|---|---|---|
73
+ | o1 | mặc định, không `--auto` | `printf … > inside.txt` bằng shell | `tool_use` shell **`completed`**, file tạo. **Không hỏi** |
74
+ | o2 | mặc định, không `--auto` | ghi file thư mục home, **ngoài workspace**, bằng shell | `tool_use` shell **`completed`**, file được tạo. **Không hỏi, không chặn** |
75
+ | o3 | `--auto` | như o2 | `completed`, file tạo |
76
+ | o4 | `permission: {edit: ask, bash: ask}` | ghi `inside.txt` | `tool_use` shell **`status: "error"`**, `error`: *"This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action."*; agent thử lại bằng tool `write`, cùng lỗi. Stderr: `! permission requested: shell (echo -n "IN_OK" > inside.txt, cat inside.txt); auto-rejecting` và `! permission requested: edit (inside.txt); auto-rejecting`. Run **đi tiếp**, thoát 0, agent kể lại việc bị từ chối |
77
+ | o5 | như o4 + `--auto` | như o4 | `completed`, file tạo → `--auto` **ghi đè** `ask` |
78
+ | o6 | `permission: {edit: deny, bash: deny}` | ghi `inside.txt` | shell/edit bị chặn; agent **chuyển sang tool khác**: gọi tool `execute` với các MCP tool của chủ máy (`tools.rea.*`, `tools.browser`), thử `capture_process_scenario` chạy `powershell.exe`, rồi tạo subagent. Các MCP tool đó trả `capability_unavailable` nên không có tác động; run dừng vì hết hạn mức free |
79
+
80
+ Ghi chú về các số đo: o1 chạy trên `opencode/big-pickle`, o2–o6 (và chạy lại o1, bị 429) trên `opencode/deepseek-v4.1-flash-free`, vì `big-pickle` hết hạn mức miễn phí giữa chừng (`provider.free-limit`, 429). o4 có kết quả trên cả hai model.
81
+
82
+ Điều rút ra:
83
+
84
+ - **`opencode run` không có duyệt giữa chừng.** Lời xin quyền bị tự từ chối tức thì.
85
+ - **Có tín hiệu từ chối rõ ràng và ổn định** để adapter đọc: `tool_use` có `part.state.status === "error"` và thông điệp chứa `cannot ask the user for permission`; stderr có dòng `permission requested: <tool> (<tham số>); auto-rejecting`. Adapter hiện trả `deniedActions: []` cố định (`src/agents/opencode.ts:91`).
86
+ - **Cấu hình mặc định không từ chối gì cả** (o1, o2): hàng đợi duyệt chỉ có việc để làm với OpenCode khi chủ máy đã đặt `permission` thành `ask` (hoặc `deny`) trong cấu hình của OpenCode. Đây là điểm khác lớn với `agy`/`codex`.
87
+ - **Từ chối `bash` không chặn việc chạy lệnh**: o6 cho thấy agent tìm đường khác qua MCP tool có sẵn trong cấu hình OpenCode của chủ máy. Một mức quyền chỉ nói về `bash`/`edit` là chưa đủ.
88
+ - `opencode` có `serve`, `acp`, `api` và cờ `--server <url>`; có thể có API duyệt quyền trong server. **Chưa đánh giá**, ngoài MVP.
89
+
90
+ ## 3. Bridge nhìn thấy gì hôm nay
91
+
92
+ | Agent | Nguồn tín hiệu từ chối | Kết quả trong bridge |
93
+ |---|---|---|
94
+ | `agy` | `denied_actions` trong JSON cuối (`antigravity.ts:39-44`) hoặc stderr khớp `auto-denied\|soft.denied\|permission.*cannot prompt` (dòng 54) | task `failed`, `denied_actions` điền |
95
+ | `codex` | item `declined`/`denied` (`codex.ts:62-63`) — **chưa từng thấy** | sự từ chối kiểu sandbox (mục 2.2) → task `completed`, `denied_actions: []` |
96
+ | `opencode` | không đọc gì (`opencode.ts:91`) | task `completed`, `denied_actions: []` |
97
+
98
+ ## 4. Việc adapter cần làm để hàng đợi có dữ liệu (thuộc B2, nếu thiết kế được duyệt)
99
+
100
+ - **opencode** (tín hiệu chắc chắn): mỗi `tool_use` có `state.status === "error"` và thông điệp khớp `/cannot ask the user for permission/` → một `denied_action` `{action: part.tool, display_name: <tham số rút gọn, đã redact>}`. Một task có `denied_actions` thì chuyển sang `failed` như `agy`, hay giữ `completed` kèm cảnh báo? **Xem câu hỏi 3.**
101
+ - **codex** (tín hiệu yếu): `command_execution` `failed` với output khớp `/access.*denied|permission denied|read-only file system/i` chỉ là **nghi ngờ** (agent có thể tự gây lỗi đó). Đề xuất: ghi vào hàng đợi như `suspected: true`, không đổi trạng thái task, và nói rõ trên giao diện rằng đó là suy đoán từ văn bản. Không dựa vào nó để tự động hóa gì.
102
+ - **agy**: giữ nguyên.
103
+
104
+ ## 5. Thiết kế MVP: "duyệt sau, chạy lại một lần"
105
+
106
+ ### 5.1 Luồng
107
+
108
+ 1. Task kết thúc; có `denied_actions` (hoặc nghi ngờ, mục 4) → một **yêu cầu duyệt** hiện trong hàng đợi và ở panel của task.
109
+ 2. Chủ bridge mở yêu cầu, thấy từng hành động bị từ chối (văn bản do CLI cung cấp, hiển thị bằng `textContent`, cắt độ dài, đã redact) và các lựa chọn:
110
+ - **Cho phép và chạy lại**: hộp thoại chọn *mức nâng* (mặc định là mức hẹp nhất dùng được cho agent đó, xem 5.2), cảnh báo nó có thể lộ ra điều gì, và ô `confirm`. Kết quả: **task mới** `retry_of: <task cũ>`, cùng yêu cầu đã lưu, kèm `elevation` của đúng lần này.
111
+ - **Bỏ qua**: đóng yêu cầu, không chạy gì.
112
+ 3. Mọi quyết định ghi vào nhật ký (`appendDecision`): hành động `approve`/`rerun` (cho phép và chạy lại, kèm mức nâng và id task mới) và `dismiss`.
113
+
114
+ ### 5.2 Quyền nâng cho đúng một lần chạy (mức hẹp nhất mà cờ cho phép)
115
+
116
+ | Agent | Mức hẹp → rộng | Mặc định | Có thể lộ ra |
117
+ |---|---|---|---|
118
+ | `agy` | `--mode accept-edits` → `--mode auto` → `--dangerously-skip-permissions` (từ `agy --help`; **chưa đo** hành động nào cần mức nào) | `accept-edits` nếu mọi hành động bị từ chối là ghi file (`write_file`, đã ghi nhận trước đây); còn lại chủ chọn tay, không đoán | `accept-edits`: sửa file trong workspace không hỏi. `auto`/`full`: lệnh và đường dẫn ngoài workspace, thông tin đăng nhập trên máy |
119
+ | `codex` | `--add-dir <thư mục>` (**đã đo**, mở đúng thư mục nêu tên) → `--sandbox danger-full-access` (**chưa đo**) | `--add-dir` cho thư mục xuất hiện trong hành động bị từ chối, khi suy ra được; ngược lại chủ nhập thư mục | `--add-dir`: ghi vào thư mục đó. `danger-full-access`: toàn bộ ổ đĩa và mạng. `--ask-for-approval` **không** giúp gì (đo: `on-request` = `never` trong `exec`) |
120
+ | `opencode` | patch quyền theo từng lần chạy qua `OPENCODE_CONFIG_CONTENT`, chỉ cho phép đúng quyền bị từ chối (ví dụ `{"permission":{"bash":"allow"}}`) → `--auto` | patch hẹp. **Chưa đo** patch này có thắng cấu hình `ask` toàn cục của người dùng không (cơ chế biến môi trường đã đo ở o4/o5; thứ tự ưu tiên thì chưa) | `bash: allow` ở OpenCode cho phép ghi **ngoài workspace** (o2) và mọi lệnh. `--auto` duyệt cả những gì cấu hình hỏi. MCP tool của chủ máy luôn nằm trong tầm với (o6) |
121
+
122
+ ### 5.3 Bảo đảm quyền chỉ áp dụng cho một lần chạy
123
+
124
+ - Quyền nâng chỉ đi vào **đối số/biến môi trường của lần launch đó** (`extraArgs`/`customEnv` đã có sẵn trong `AgentTask`) và vào trường `elevation` của bản ghi task mới. **Không ghi** `permissions.json`, `workspaces.json`, cấu hình CLI hay biến môi trường của bridge; cài đặt toàn cục của chủ máy không đổi.
125
+ - Task chạy lại **không kế thừa** gì cho task sau: chạy lại một task chạy lại cần một lần duyệt mới. `retry_of` chỉ để tra cứu, không mang quyền.
126
+ - Mức nâng có **trần** là lựa chọn của chủ trong hộp thoại xác nhận; lúc launch, bridge kiểm tra lại `elevation` trên bản ghi chứ không tin giá trị nào khác (cùng tinh thần `lowerLevel` ở `permissions.ts`: không có đường nào làm quyền tăng sau khi chủ đã chọn).
127
+ - Task cũ được giữ nguyên làm bằng chứng; task mới dùng **đúng yêu cầu đã lưu của task cũ** (không lấy văn bản từ đầu ra của agent).
128
+
129
+ ### 5.4 Chỉ chủ bridge duyệt, và agent không tự duyệt được
130
+
131
+ - Chỉ qua **web UI** (`POST /ui/api/…`, `confirm: true`), cùng mô hình tin cậy với "Áp dụng/Bỏ worktree" (#40) và dừng mission. **Không thêm tool MCP nào** để xem hàng đợi, duyệt hay nâng quyền; `tests/http.test.ts` và `tests/bridge.test.ts` đều đếm số tool nên thêm nhầm sẽ đỏ.
132
+ - Văn bản trong yêu cầu (tên hành động, tham số) do CLI/agent tạo ra nên **không tin cậy**: hiển thị bằng `textContent`, giới hạn độ dài, qua `redact`; không bao giờ đưa vào một câu lệnh hay vào prompt của lần chạy lại.
133
+ - Một agent không thể tạo yêu cầu duyệt cho chính nó ngoài việc bị từ chối thật, và không có đường nào từ nội dung nó viết tới nút "Cho phép".
134
+
135
+ ### 5.5 Dữ liệu
136
+
137
+ Không thêm kho trạng thái mới. Yêu cầu duyệt được **suy ra** từ bản ghi task (`denied_actions`, trường mới `retry_of`/`elevation`) và từ `decisions.jsonl` (đã có, chỉ thêm giá trị `approve`/`rerun`/`dismiss` vào `DecisionAction`). Một yêu cầu là "chờ" khi task có `denied_actions` và chưa có quyết định nào cho task đó. Hệ quả: khởi động lại bridge không làm mất hàng đợi, và nhật ký vẫn là nguồn duy nhất.
138
+
139
+ ### 5.6 Task `isolation: worktree`
140
+
141
+ Đề xuất: lần chạy lại tạo **worktree mới từ HEAD** (sạch), worktree cũ giữ nguyên để chủ xem và bỏ. Cái giá: mất phần việc dở của lần đầu. Phương án khác (dùng lại worktree) giữ việc dở nhưng tái hiện đúng vấn đề đã ghi ở `docs/SMART-ROUTER-PLAN.md` (phạm vi là tổng hai lần). **Xem câu hỏi 2.**
142
+
143
+ ## 6. Ranh giới của MVP
144
+
145
+ Ngoài MVP và chưa kiểm: duyệt giữa chừng (API server của OpenCode chưa đánh giá); mọi thứ về `agy` 1.4.12; hình dạng `denied_actions` ngoài `write_file`; `codex --sandbox danger-full-access`; patch cấu hình OpenCode có thắng cấu hình toàn cục không; hành vi trên Linux/macOS (mọi số đo ở trên là Windows).
146
+
147
+ ## 7. Quan sát ngoài phạm vi (không sửa trong PR này)
148
+
149
+ 1. **OpenCode `provider.free-limit` không được nhận là hết quota.** Run bị 429 `FreeUsageLimitError` ("Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models."): `isQuotaError` trả `false` cho cả thông điệp lẫn `provider.free-limit` (đã chạy), nên một pool có OpenCode **không failover** khi hạn mức miễn phí hết.
150
+ 2. **OpenCode chạy với MCP tool của chủ máy** (o6): từ chối `bash` không chặn việc thử chạy lệnh qua tool khác. Đáng cân nhắc cô lập cấu hình OpenCode của task (cấu hình riêng qua `OPENCODE_CONFIG_CONTENT`/`XDG_*`).
151
+ 3. **Codex nạp MCP server của người dùng, kể cả chính bridge** (stderr mục 2.2). Khi bridge đang chạy ở cổng đó, một task `codex` do bridge giao có thể gọi lại `antigravity_delegate` — một đường đệ quy chưa được chặn. Chưa đo (trong các lần đo bridge không chạy ở cổng 3000).
152
+ 4. Sandbox Windows của codex thỉnh thoảng thất bại ở khâu khởi tạo (c1, `Some(143)`); bridge sẽ thấy lệnh `failed` mà không có dấu hiệu từ chối.
153
+
154
+ ## 8. Câu hỏi cần chủ dự án quyết trước khi viết mã
155
+
156
+ 1. **Chấp nhận "duyệt sau"** (không duyệt giữa chừng) làm MVP? Đây là kết luận của các số đo ở mục 2.
157
+ 2. **Task worktree chạy lại**: worktree mới từ HEAD (đề xuất) hay dùng lại worktree cũ?
158
+ 3. **Task có `denied_actions` ở OpenCode/Codex**: giữ `completed` kèm cảnh báo, hay chuyển `failed` như `agy`? (Đổi trạng thái là đổi hành vi hiện tại, ảnh hưởng mission và pool failover.)
159
+ 4. **Codex "nghi ngờ"** (suy ra từ văn bản): có đưa vào hàng đợi (gắn nhãn suy đoán), hay chỉ làm khi CLI báo `declined`/`denied` thật?
160
+ 5. **`agy`**: đăng nhập `agy` trên máy này để đo mục 2.1 trước khi viết mã, hay chấp nhận thiết kế dựa trên bằng chứng cũ và cờ trong `--help`?
161
+ 6. Có làm việc ngoài phạm vi ở mục 7 (đặc biệt mục 1, thiếu nhận diện hết hạn mức OpenCode) thành PR riêng không?
@@ -5,6 +5,24 @@
5
5
 
6
6
  Không thay đổi hành vi hiện tại của antigravity và codex. OpenCode nằm trong **Phase 3** theo `docs/MULTI-AGENT-PLAN.md`.
7
7
 
8
+ ## Đã làm khác thiết kế ban đầu (đọc mục này trước các mục 1–3)
9
+
10
+ Các mục 1–3 bên dưới là bản thiết kế viết trước khi có code, giữ lại để biết lý do. Nơi nào mâu thuẫn với bảng này thì **bảng này đúng** (đã đối chiếu với `opencode` v2.2.5 và với code):
11
+
12
+ | Thiết kế ban đầu | Thực tế |
13
+ |---|---|
14
+ | `opencode` vào `DEFAULT_ENABLED` | **Mặc định tắt**; chỉ chạy khi `agents.enabled` liệt kê. Profile OpenCode khai báo mà bỏ `agents.enabled` vẫn được kiểm tra CLI và báo lý do nếu không dùng được |
15
+ | `opencode run <prompt>` qua argv | `opencode run --standalone --format json`, prompt qua **stdin** (argv bị giới hạn độ dài, và prompt bắt đầu bằng `-` bị đọc thành cờ) |
16
+ | không nhắc service nền | **`--standalone` bắt buộc**: mặc định `opencode run` giao việc cho service nền dùng chung của OpenCode Desktop, hủy task không dừng agent và shell của agent nằm ngoài cây process mà bridge dọn (đo ở `docs/HANDOFF.md` mục 7) |
17
+ | `OPENCODE_HOME` tách tài khoản | Biến này **không có tác dụng**; dùng `XDG_DATA_HOME` / `XDG_CONFIG_HOME` trong `profile.env` |
18
+ | model `gpt-5` | Dạng `provider/model` (xem `opencode models`), ví dụ `opencode/big-pickle` |
19
+ | `parseResult` đọc JSON đơn hoặc text thường | Đọc luồng JSONL: sự kiện `text` cuối là câu trả lời, `step_finish` cho lượt và token, `error` là thất bại |
20
+ | `autonomy: "full"` có thể thêm `--auto` | **Không bao giờ**: `autonomy` do client chọn và chỉ là chỉ dẫn. `--auto` chỉ bật bằng `agents.opencode_auto_approve` trong config của chủ máy, và hiện trong cảnh báo của `agents_list` |
21
+ | không có giới hạn log | `compactLine` bỏ `step_start` và rút `tool_use` về tên tool và trạng thái, để luồng JSON không vượt giới hạn output |
22
+ | quyền mặc định không bàn tới | OpenCode **chạy lệnh shell và sửa file mà không hỏi** kể cả không có `--auto` (đo trên v2.2.5): mô hình quyền của nó lỏng hơn mặc định của `agy` và Codex `workspace-write`. Bật nó trong `agents.enabled` là chấp nhận điều đó |
23
+
24
+ Mục 3 ("Câu hỏi cần trả lời") đã được trả lời bằng bảng trên; không còn câu hỏi mở.
25
+
8
26
  ---
9
27
 
10
28
  ## 1. Khai báo kỹ thuật
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Local loopback HTTP MCP bridge that delegates bounded coding tasks to multiple agent CLIs (Antigravity, Codex) with optional parallel git worktrees",
5
5
  "license": "MIT",
6
6
  "keywords": [