agy-mcp-bridge 0.1.6 → 0.1.7

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 CHANGED
@@ -220,7 +220,7 @@ Tham khảo [tài liệu MCP chính thức của Claude Code](https://code.claud
220
220
  {"permissions":{"allow":["write_file(C:/absolute/path/to/approved-project/)"]}}
221
221
  ```
222
222
 
223
- Merge rule cần thiết vào settings, không thay toàn bộ cấu hình; không dùng wildcard global hay bypass permissions. Test commands phải được cho phép riêng; Codex có thể tự chạy test. Deploy/xóa dữ liệu/thay đổi hệ thống yêu cầu xác nhận tương tác riêng và không thuộc task thông thường. Chi tiết hạn chế CLI ở [docs/VERIFICATION.md](docs/VERIFICATION.md).
223
+ Merge rule cần thiết vào settings, không thay toàn bộ cấu hình; không dùng wildcard global. Muốn Antigravity tự làm mà không bị từ chối từng thao tác, chủ máy có thể nâng mức quyền trong giao diện web (Cài đặt → Quyền của Antigravity, xem mục 10); mặc định vẫn là không bypass. Test commands phải được cho phép riêng; Codex có thể tự chạy test. Deploy/xóa dữ liệu/thay đổi hệ thống yêu cầu xác nhận tương tác riêng và không thuộc task thông thường. Chi tiết hạn chế CLI ở [docs/VERIFICATION.md](docs/VERIFICATION.md).
224
224
 
225
225
  Luồng sử dụng trong Codex hoặc Claude Code:
226
226
 
@@ -376,6 +376,20 @@ Lưu ý bảo mật: nội dung yêu cầu và phản hồi được hiển th
376
376
 
377
377
  **Ghi chú phát hành:** mỗi lần workflow phát hành, nó tạo một GitHub Release cho tag `vX.Y.Z` với ghi chú tự sinh gồm các pull request đã merge kể từ tag trước (tên PR là nội dung ghi chú, nên hãy đặt tên PR nêu rõ thay đổi). Xem tại trang Releases của repo. Các tag cũ chưa có release cũng được tạo bù, theo thứ tự từ cũ đến mới.
378
378
 
379
+ **Quyền của Antigravity:** mục Cài đặt → Quyền của Antigravity chọn Antigravity được tự làm đến đâu khi chạy task. Ở chế độ không tương tác, Antigravity tự từ chối việc cần phê duyệt (thấy ở `denied_actions`), nên task sửa file hay chạy lệnh có thể không làm được nếu cài đặt Antigravity chưa cấp quyền. Năm mức, từ thấp đến cao:
380
+
381
+ | Mức | Cờ truyền cho `agy` | Ý nghĩa |
382
+ |---|---|---|
383
+ | Mặc định | (không có) | Quyền theo file cài đặt của Antigravity; an toàn nhất, là mặc định |
384
+ | Chỉ lập kế hoạch | `--mode plan` | Chỉ lập kế hoạch |
385
+ | Tự duyệt sửa file | `--mode accept-edits` | Sửa file được duyệt tự động |
386
+ | Tự động | `--mode auto` | Chế độ tự động của Antigravity |
387
+ | Toàn quyền | `--dangerously-skip-permissions` | Duyệt mọi yêu cầu, gồm chạy lệnh và truy cập ngoài thư mục dự án |
388
+
389
+ Chỉ chủ máy đổi được, qua giao diện web (cần phiên đăng nhập bằng token); Codex/Claude không có tool nào để đổi và file skill cấm xin token để làm việc đó. Mức `Tự động` và `Toàn quyền` yêu cầu xác nhận; khi đang bật, giao diện hiện dải cảnh báo, `antigravity_environment` báo `agy_permissions` để Codex/Claude biết và nói với bạn, và chi tiết mỗi task ghi mức quyền đã dùng. Có hiệu lực ngay, không cần khởi động lại. Hạ mức xuống thì áp dụng cả cho task đang xếp hàng; nâng lên thì không (task giữ mức lúc được nhận). Biến `BRIDGE_AGY_PERMISSIONS=default|plan|accept-edits|auto|full` đặt cố định và khóa giao diện (giá trị sai thì dùng `default`). Lựa chọn lưu ở `.bridge-state/permissions.json`.
390
+
391
+ **Cảnh báo:** danh sách "Workspace được phép" chỉ kiểm tra đường dẫn của task, không phải sandbox; với `Tự động` hay `Toàn quyền`, Antigravity có thể tác động ra ngoài thư mục đó (xóa file, chạy lệnh, dùng thông tin đăng nhập đã lưu). Chỉ bật khi bạn tin tưởng tác vụ, dự án đã được git theo dõi hoặc sao lưu. Các cờ `--mode` chưa được kiểm chứng với `agy` thật qua chế độ `--print`.
392
+
379
393
  ## 11. Ghi chú thiết kế và việc chưa làm
380
394
 
381
395
  - **Tiến độ thật của task:** stdin stream-json hiện đã được xác minh ở đường kiểm tra lỗi không gọi AI, và parser lấy terminal `result` theo tài liệu chính thức. Luồng thành công đang được test bằng mock; chưa thực thi AI thật trong đợt hardening. UI hiện hiển thị log snapshot, chưa diễn giải từng `step_update` thành phần trăm tiến độ. Trong lúc task chạy, trang chi tiết có thanh thời gian đã chạy so với giới hạn, thời điểm `agy` ghi output gần nhất, và danh sách thay đổi trong workspace tự làm mới mỗi vài giây khi mở mục "Thay đổi trong workspace" (các tín hiệu này không phụ thuộc định dạng output của `agy`; ở chế độ print/json `agy` chỉ in khi xong nên dòng output có thể trống).
@@ -1,5 +1,5 @@
1
- export function invocationFor(prompt, timeout, transport, model = null) {
2
- const common = ['--print-timeout', `${timeout}s`, '--disable-slash-commands', ...(model ? ['--model', model] : [])];
1
+ export function invocationFor(prompt, timeout, transport, model = null, extraArgs = []) {
2
+ const common = ['--print-timeout', `${timeout}s`, '--disable-slash-commands', ...(model ? ['--model', model] : []), ...extraArgs];
3
3
  return transport === 'argv'
4
4
  ? { args: ['--print', prompt, '--output-format', 'json', ...common], stdin: undefined }
5
5
  : { args: ['--input-format', 'stream-json', '--output-format', 'stream-json', ...common], stdin: JSON.stringify({ event: 'user', message: { content: prompt } }) + '\n' };
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ /**
5
+ * How much Antigravity may do on its own while a delegated task runs, lowest to highest.
6
+ * Chosen by the bridge owner in the web UI (or BRIDGE_AGY_PERMISSIONS); never by an MCP client.
7
+ * default no flag: Antigravity's own settings decide (headless runs soft-deny anything that needs approval)
8
+ * plan --mode plan: planning only
9
+ * accept-edits --mode accept-edits: file edits are approved automatically
10
+ * auto --mode auto: Antigravity's automatic mode
11
+ * full --dangerously-skip-permissions: every tool request is approved, including commands and paths outside the workspace
12
+ */
13
+ export const PERMISSION_LEVELS = ['default', 'plan', 'accept-edits', 'auto', 'full'];
14
+ export const PERMISSION_FILE = 'permissions.json';
15
+ export const isPermissionLevel = (value) => typeof value === 'string' && PERMISSION_LEVELS.includes(value);
16
+ /** Levels that need an explicit acknowledgement before they can be set. */
17
+ export const needsAcknowledgement = (level) => level === 'auto' || level === 'full';
18
+ export function permissionFlags(level) {
19
+ switch (level) {
20
+ case 'plan': return ['--mode', 'plan'];
21
+ case 'accept-edits': return ['--mode', 'accept-edits'];
22
+ case 'auto': return ['--mode', 'auto'];
23
+ case 'full': return ['--dangerously-skip-permissions'];
24
+ default: return [];
25
+ }
26
+ }
27
+ /** The less privileged of two levels (used so lowering the setting also applies to tasks that are already queued). */
28
+ export const lowerLevel = (a, b) => PERMISSION_LEVELS.indexOf(a) <= PERMISSION_LEVELS.indexOf(b) ? a : b;
29
+ /**
30
+ * The environment variable wins and locks the setting. Anything unreadable or unknown means "default" (least privilege), never a higher level.
31
+ * Read synchronously and on every task, so a change needs no restart.
32
+ */
33
+ export function permissionState(stateDirectory, env = process.env) {
34
+ const forced = env.BRIDGE_AGY_PERMISSIONS?.trim().toLowerCase();
35
+ if (forced !== undefined && forced !== '') {
36
+ return isPermissionLevel(forced)
37
+ ? { level: forced, source: 'env', controllable: false, reason: 'BRIDGE_AGY_PERMISSIONS' }
38
+ : { level: 'default', source: 'env', controllable: false, reason: `BRIDGE_AGY_PERMISSIONS has an invalid value (use ${PERMISSION_LEVELS.join(', ')}); using default` };
39
+ }
40
+ try {
41
+ const level = JSON.parse(readFileSync(path.join(stateDirectory, PERMISSION_FILE), 'utf8')).level;
42
+ if (isPermissionLevel(level))
43
+ return { level, source: 'file', controllable: true, reason: null };
44
+ }
45
+ catch { /* no file, or unreadable: default */ }
46
+ return { level: 'default', source: 'default', controllable: true, reason: null };
47
+ }
48
+ export async function writePermission(stateDirectory, level) {
49
+ const file = path.join(stateDirectory, PERMISSION_FILE);
50
+ if (level === 'default') {
51
+ await rm(file, { force: true });
52
+ return;
53
+ }
54
+ await mkdir(stateDirectory, { recursive: true });
55
+ await writeFile(`${file}.tmp`, JSON.stringify({ level, set_at: new Date().toISOString() }) + '\n', { mode: 0o600 });
56
+ await rename(`${file}.tmp`, file);
57
+ }
58
+ //# sourceMappingURL=permissions.js.map
@@ -38,7 +38,7 @@ export async function createRuntime() {
38
38
  'CLI SUCCESS/exit 0 does not establish acceptance; tool permissions may have been denied.',
39
39
  'Workspace preflight is not an OS sandbox and cannot prevent concurrent link swaps.',
40
40
  'Existing Antigravity permissions, hooks, MCP and credential access remain governed by its configuration.',
41
- 'No permission bypass flags. Dangerous actions require separate interactive approval.',
41
+ 'Permission flags are off unless the bridge owner raises the Antigravity permission level in the web UI (agy_permissions shows the current level; a client cannot change it). At the default level, actions that need approval are denied (see denied_actions).',
42
42
  'Windows uses taskkill on the live owned child tree; abrupt server termination may orphan processes.',
43
43
  'Redaction recognizes known patterns only. Never include credentials in task data.'
44
44
  ]
@@ -9,6 +9,7 @@ import { delegationState } from '../delegation.js';
9
9
  import { startProcess, terminateOwned } from '../antigravity/process.js';
10
10
  import { invocationFor, finalEnvelope } from '../antigravity/invocation.js';
11
11
  import { packageRoot } from '../paths.js';
12
+ import { lowerLevel, permissionFlags, permissionState } from '../permissions.js';
12
13
  import { listModels, readModel } from '../models.js';
13
14
  import { pidAlive, recoverExecutionLock } from './execution-lock.js';
14
15
  const TASK_FILE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/;
@@ -72,7 +73,8 @@ export class TaskManager {
72
73
  task: redact(input.task), acceptance_criteria: input.acceptance_criteria.map(redact),
73
74
  context: input.context === undefined ? null : redact(input.context), timeout_seconds: timeout,
74
75
  client: meta.client ? redact(meta.client).slice(0, 100) : null,
75
- model: await readModel(this.runtime.stateDirectory)
76
+ model: await readModel(this.runtime.stateDirectory),
77
+ permission: permissionState(this.runtime.stateDirectory).level
76
78
  }
77
79
  };
78
80
  this.entries.set(task.task_id, entry);
@@ -143,7 +145,11 @@ export class TaskManager {
143
145
  task.started_at = new Date().toISOString();
144
146
  await this.persist(task);
145
147
  checkStarting();
146
- const invocation = invocationFor(promptFor({ ...input, workspace }, entry.request?.client), timeout, this.config.prompt_transport, entry.request?.model ?? null);
148
+ // The level in force when the task was accepted, or the current one if the owner has lowered it since: lowering applies to queued tasks too, raising never does.
149
+ const permission = lowerLevel(entry.request?.permission ?? 'default', permissionState(this.runtime.stateDirectory).level);
150
+ if (entry.request)
151
+ entry.request.permission = permission;
152
+ const invocation = invocationFor(promptFor({ ...input, workspace }, entry.request?.client), timeout, this.config.prompt_transport, entry.request?.model ?? null, permissionFlags(permission));
147
153
  const options = { extraEnvPassthrough: this.config.extra_env_passthrough, stdin: invocation.stdin };
148
154
  const child = this.runtime.launch ? this.runtime.launch(invocation.args, workspace, options) : startProcess(this.runtime.executable, invocation.args, workspace, options);
149
155
  entry.child = child;
@@ -481,7 +487,7 @@ export class TaskManager {
481
487
  return this.describeLock(lock);
482
488
  }
483
489
  overview() {
484
- return { accepting_tasks: !this.closing, queue: { queued: this.queue.length, max: this.config.max_queued_tasks }, delegation: delegationState(this.runtime.stateDirectory), recent_tasks: [...this.entries.values()].map(({ task }) => ({
490
+ return { accepting_tasks: !this.closing, queue: { queued: this.queue.length, max: this.config.max_queued_tasks }, delegation: delegationState(this.runtime.stateDirectory), agy_permissions: permissionState(this.runtime.stateDirectory).level, recent_tasks: [...this.entries.values()].map(({ task }) => ({
485
491
  task_id: task.task_id, status: task.status, started_at: task.started_at, ended_at: task.ended_at
486
492
  })) };
487
493
  }
@@ -7,6 +7,7 @@ import { paths } from '../paths.js';
7
7
  import { knownUpdate, readPackageInfo } from '../update.js';
8
8
  import { workspaceChanges } from '../changes.js';
9
9
  import { validModelId, writeModel } from '../models.js';
10
+ import { PERMISSION_LEVELS, isPermissionLevel, needsAcknowledgement, permissionState, writePermission } from '../permissions.js';
10
11
  import { inside } from '../security/workspace.js';
11
12
  const assets = {
12
13
  '/ui': { file: 'index.html', type: 'text/html; charset=utf-8' },
@@ -53,6 +54,7 @@ async function overview(ctx) {
53
54
  limits: env.limits ?? {}, allowed_workspaces: env.allowed_workspaces ?? [], session_workspaces: env.session_workspaces ?? [], limitations: env.limitations ?? [],
54
55
  delegation: { ...state, controllable: delegationControllable() },
55
56
  model: await ctx.manager.currentModel(),
57
+ permission: permissionState(ctx.manager.stateDirectory),
56
58
  queue: ctx.manager.overview().queue,
57
59
  warnings: [await ctx.manager.lockDiagnostic()].filter(Boolean),
58
60
  update: knownUpdate() && { ...knownUpdate(), package: readPackageInfo().name, command: 'agy-mcp-bridge update' },
@@ -164,6 +166,24 @@ export async function handleUi(req, res, ctx) {
164
166
  json(res, 200, { ...delegationState(ctx.manager.stateDirectory), controllable: true });
165
167
  return true;
166
168
  }
169
+ if (pathname === '/ui/api/permission' && method === 'POST') {
170
+ const body = await readJson(req);
171
+ if (!isPermissionLevel(body.level)) {
172
+ json(res, 400, { error: `level must be one of: ${PERMISSION_LEVELS.join(', ')}` });
173
+ return true;
174
+ }
175
+ if (!permissionState(ctx.manager.stateDirectory).controllable) {
176
+ json(res, 409, { error: 'The permission level is locked by BRIDGE_AGY_PERMISSIONS in the server environment' });
177
+ return true;
178
+ }
179
+ if (needsAcknowledgement(body.level) && body.acknowledge_risk !== true) {
180
+ json(res, 400, { error: 'This level lets Antigravity act without asking; send acknowledge_risk: true to confirm' });
181
+ return true;
182
+ }
183
+ await writePermission(ctx.manager.stateDirectory, body.level);
184
+ json(res, 200, permissionState(ctx.manager.stateDirectory));
185
+ return true;
186
+ }
167
187
  if (pathname === '/ui/api/models' && method === 'GET') {
168
188
  const current = await ctx.manager.currentModel();
169
189
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Local authenticated HTTP MCP bridge that lets Codex and Claude Code delegate bounded coding tasks to the Antigravity CLI (agy)",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -30,6 +30,7 @@ A decline for one task does not change the setting for later tasks; ask again on
30
30
 
31
31
  - The bridge owner chooses the Antigravity model (web UI, Settings → Model); `antigravity_delegate` has no model parameter. Never claim a specific model ran the task, and never try to change it. The model used is recorded on the task in the web UI.
32
32
  - Fit the task to that setting: keep tasks small and well specified so a fast model can finish them. If a bounded task fails or comes back poor and the cause looks like model capability (not missing context or permissions), tell the user once that a stronger model may help (Settings → Model), instead of resubmitting the same task.
33
+ - If `agy_permissions` is `auto` or `full`, tell the user once that delegated tasks run without approval prompts (`full` also reaches outside the workspace), keep the task and its scope narrow, and check for changes outside the workspace when you review. A lower level is not a reason to retry the same task with different wording to get around a denial.
33
34
  - A running task has no step-by-step progress in these tools. Do not poll `antigravity_task_output` for it; `antigravity_task_wait` is enough. The user can watch elapsed time, last output and workspace changes live in the web UI.
34
35
 
35
36
  ## Submit one bounded task
@@ -72,7 +73,7 @@ Client disconnect does not cancel an accepted task. Reconnect to the same runnin
72
73
  | `queued` | Accepted but not started: no process, no file changes yet. Keep waiting with `antigravity_task_wait` (the deadline starts when it begins running). It is refused/canceled if delegation is switched off before its turn. Cancel it through the cancel tool only if the user cancels. |
73
74
  | Still `running` | Continue within the deadline; cancel your own task through the cancel tool if the user cancels or it is demonstrably stuck. |
74
75
 
75
- - `denied_actions` or permission errors require an authorized, narrowly scoped grant or a different plan. Do not add bypass flags, broad allow rules, or automatically approve commands. Approval to code does not automatically grant CLI command permissions.
76
+ - `denied_actions` or permission errors require an authorized, narrowly scoped grant or a different plan. Do not add bypass flags, broad allow rules, or automatically approve commands. Approval to code does not automatically grant CLI command permissions. The bridge owner sets how much Antigravity may do on its own (web UI, Settings; `antigravity_environment` shows it as `agy_permissions`: `default`, `plan`, `accept-edits`, `auto` or `full`). You cannot change it and must not ask for the bridge token to do so. If denials block the task, tell the user once which actions were denied and that they can raise the level in the web UI or add narrow rules to Antigravity's own settings.
76
77
  - Exit 0 / CLI `SUCCESS` can accompany soft-denied tools or partial timeout output. Follow bridge status, error and truncation metadata. Truncated stdout is not a complete JSON result.
77
78
  - On "Bridge is busy" or "queue is full", reconcile existing tasks: wait for them rather than resubmitting. On a lock error, reconcile existing tasks too. Do not remove execution locks or kill processes by name. If the lock looks stale, tell the user that the bridge owner can run `agy-mcp-bridge unlock`, which reclaims a lock only when both recorded process IDs are dead; do not run it yourself unless the user asks. Cancel only your known task ID. Cancellation is graceful: the bridge stops the process group and waits up to about 8 seconds on Linux/macOS before forcing it, so a cancel call can take several seconds; wait for it instead of calling again. If cancellation fails, stop further delegation until the owned process is inspected.
78
79
  - Retry a transient connection/status read once after reconnecting when safe. Repeated failures should be reported, not hidden by an unlimited retry loop. A new coding attempt needs evidence of a specific fix or a changed instruction/permission; do not repeat the same failed task unchanged.
@@ -28,6 +28,10 @@
28
28
  {
29
29
  "sha256": "451fe5791b1753f1550275a709c561442e62d1e8b86d3fe8c4525998d8d85659",
30
30
  "note": "model choice is the owner's setting; no step progress in the tools"
31
+ },
32
+ {
33
+ "sha256": "65c0a72bb016ee8d891bfea17ea5d972dc79dc7c12c57edb17c1e6e8bbd37078",
34
+ "note": "Antigravity permission level is the owner's setting (agy_permissions)"
31
35
  }
32
36
  ]
33
37
  }
package/ui/app.css CHANGED
@@ -210,3 +210,9 @@ pre.log { margin: 0; padding: 12px; border-radius: 10px; background: #0b0e14; co
210
210
  .progress { display: grid; gap: 6px; margin: 4px 0 12px; }
211
211
  .bar { height: 6px; border-radius: 999px; background: var(--surface-2); overflow: hidden; border: 1px solid var(--border); }
212
212
  .bar > span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .9s linear; }
213
+ .perm-list { display: grid; gap: 8px; margin: 12px 0; }
214
+ .perm-row { display: flex; gap: 12px; align-items: flex-start; padding: 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; }
215
+ .perm-row.on { border-color: var(--accent); background: var(--surface-2); }
216
+ .perm-row.danger.on { border-color: var(--bad); }
217
+ .perm-row input { margin-top: 4px; }
218
+ .perm-row > span { display: grid; gap: 2px; }
package/ui/app.js CHANGED
@@ -160,6 +160,21 @@ async function setModel(model) {
160
160
  try { await api('model', {method: 'POST', body: {model}}); notify(model ? 'Đã đổi model cho các task mới' : 'Đã về model mặc định của Antigravity'); state.models = null; await refresh(); }
161
161
  catch (error) { notify(error.message); await refresh(); }
162
162
  }
163
+ const PERMISSION_LEVELS = [
164
+ {id: 'default', title: 'Mặc định của Antigravity', text: 'Không truyền cờ nào. Quyền do file cài đặt của Antigravity quyết định; việc cần phê duyệt sẽ bị từ chối. An toàn nhất.'},
165
+ {id: 'plan', title: 'Chỉ lập kế hoạch', text: 'Antigravity chỉ lập kế hoạch (--mode plan), không sửa gì.'},
166
+ {id: 'accept-edits', title: 'Tự duyệt sửa file', text: 'Việc sửa file được duyệt tự động (--mode accept-edits). Lệnh vẫn cần quyền.'},
167
+ {id: 'auto', title: 'Tự động', text: 'Chế độ tự động của Antigravity (--mode auto). Nó tự quyết định hành động.'},
168
+ {id: 'full', title: 'Toàn quyền (nguy hiểm)', text: 'Duyệt mọi yêu cầu, gồm chạy lệnh và truy cập cả ngoài thư mục dự án (--dangerously-skip-permissions). Danh sách thư mục được phép KHÔNG ngăn được điều này.'}
169
+ ];
170
+ async function setPermission(level) {
171
+ const risky = level === 'auto' || level === 'full';
172
+ if (risky && !confirm(level === 'full'
173
+ ? 'TOÀN QUYỀN: Antigravity sẽ được tự động duyệt mọi thao tác, gồm chạy lệnh, xóa hoặc sửa file, kể cả ngoài thư mục dự án. Bridge không phải là sandbox. Chỉ bật khi bạn tin tưởng tác vụ và có bản sao lưu (git). Tiếp tục?'
174
+ : 'Chế độ tự động: Antigravity tự quyết định hành động mà không hỏi bạn. Tiếp tục?')) { await refresh(); return; }
175
+ try { await api('permission', {method: 'POST', body: {level, acknowledge_risk: risky}}); notify('Đã đổi quyền cho các task mới'); await refresh(); }
176
+ catch (error) { notify(error.message); await refresh(); }
177
+ }
163
178
  async function copy(text) {
164
179
  try { await navigator.clipboard.writeText(text); notify('Đã sao chép'); } catch { notify('Không thể sao chép tự động'); }
165
180
  }
@@ -278,7 +293,7 @@ function detailView() {
278
293
  const facts = h('dl', {class: 'kv'},
279
294
  ...[['Gửi lúc', clock(task.queued_at || task.started_at)], ['Bắt đầu chạy', queued ? 'Chưa chạy' : clock(task.started_at)], ['Kết thúc', clock(task.ended_at)],
280
295
  ['Thời gian chạy', running ? elapsedEl(task.started_at) : queued ? '—' : duration(task.duration_ms)],
281
- ['Giới hạn', request ? `${request.timeout_seconds}s` : '—'], ['Model', request && 'model' in request ? request.model || 'mặc định' : '—'], ['Exit code', task.exit_code ?? '—'], ['PID', task.pid ?? '—'],
296
+ ['Giới hạn', request ? `${request.timeout_seconds}s` : '—'], ['Model', request && 'model' in request ? request.model || 'mặc định' : '—'], ['Quyền', request && request.permission ? (PERMISSION_LEVELS.find(l => l.id === request.permission)?.title || request.permission) : '—'], ['Exit code', task.exit_code ?? '—'], ['PID', task.pid ?? '—'],
282
297
  ['CLI status', task.cli_status ?? '—']].map(([k, v]) => h('div', {}, h('dt', {text: k}), h('dd', {}, typeof v === 'string' || typeof v === 'number' ? String(v) : v))));
283
298
 
284
299
  const asker = request?.client ? `Tech Lead · ${request.client}` : 'Tech Lead (Codex / Claude)';
@@ -345,6 +360,17 @@ function modelCard(o) {
345
360
  loaded?.error ? h('div', {class: 'callout warn', text: `Không lấy được danh sách model: ${loaded.error}`}) : null,
346
361
  h('p', {text: 'Áp dụng cho task mới, không cần khởi động lại. Task đang chạy hoặc đang chờ giữ model đã chốt lúc nhận. Danh sách lấy từ `agy models`; model được ghi lại trong từng task.'}));
347
362
  }
363
+ function permissionCard(o) {
364
+ const p = o.permission || {level: 'default', controllable: true};
365
+ return h('section', {class: 'card panel wide'}, h('h2', {text: 'Quyền của Antigravity'}),
366
+ h('p', {text: 'Mức quyền Antigravity có khi làm task. Chỉ bạn đổi được ở đây; Codex / Claude không đổi được. Áp dụng cho task mới, không cần khởi động lại. Hạ mức xuống thì áp dụng cả cho task đang chờ; nâng mức lên thì không.'}),
367
+ h('div', {class: 'perm-list', role: 'radiogroup', 'aria-label': 'Quyền của Antigravity'}, PERMISSION_LEVELS.map(level =>
368
+ h('label', {class: `perm-row${level.id === 'full' ? ' danger' : ''}${p.level === level.id ? ' on' : ''}`},
369
+ h('input', {type: 'radio', name: 'perm', value: level.id, checked: p.level === level.id, disabled: !p.controllable, onchange: () => setPermission(level.id)}),
370
+ h('span', {}, h('strong', {text: level.title}), h('span', {class: 'muted', text: level.text}))))),
371
+ p.controllable ? null : h('div', {class: 'callout warn', text: `Đang bị khóa bởi biến môi trường BRIDGE_AGY_PERMISSIONS${p.reason && p.reason !== 'BRIDGE_AGY_PERMISSIONS' ? ` (${p.reason})` : ''}. Đổi biến rồi khởi động lại server để mở.`}),
372
+ h('div', {class: 'callout warn', text: 'Danh sách "Workspace được phép" chỉ kiểm tra đường dẫn của task, không phải sandbox. Với mức Tự động hoặc Toàn quyền, Antigravity có thể tác động ra ngoài thư mục đó.'}));
373
+ }
348
374
  function settingsView(o) {
349
375
  const delegation = o.delegation;
350
376
  return [h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Cài đặt'}), h('p', {text: 'Trạng thái và giới hạn của bridge. Phần lớn chỉ đọc; chỉnh bằng file cấu hình rồi khởi động lại.'}))),
@@ -356,6 +382,7 @@ function settingsView(o) {
356
382
  delegation.controllable ? h('p', {text: 'Tắt để buộc Codex / Claude tự làm. Task đang chạy hoặc đang chờ không bị dừng, nhưng task đang chờ sẽ bị hủy khi đến lượt. Có hiệu lực ngay, không cần khởi động lại.'})
357
383
  : h('div', {class: 'callout warn', text: 'Đang bị khóa bởi biến môi trường BRIDGE_DELEGATION. Đổi biến rồi khởi động lại server để mở.'})),
358
384
  modelCard(o),
385
+ permissionCard(o),
359
386
  h('section', {class: 'card panel'}, h('h2', {text: 'Kết nối'}),
360
387
  h('div', {class: 'copy-row'}, h('code', {text: o.server.mcp_endpoint}), h('button', {class: 'btn small', onclick: () => copy(o.server.mcp_endpoint), text: 'Sao chép'})),
361
388
  h('dl', {class: 'rows'}, row('Giao thức', 'MCP Streamable HTTP'), row('Xác thực', 'Bearer token dùng chung'), row('Phiên bản bridge', o.server.version),
@@ -419,9 +446,10 @@ function patch() {
419
446
  const o = state.overview;
420
447
  if (!regions || !o) return;
421
448
  setRegion('live', String(state.connected), () => [h('span', {class: `dot${state.connected ? '' : ' off'}`}), h('span', {text: state.connected ? 'Đang cập nhật trực tiếp' : 'Mất kết nối tới bridge'})]);
422
- setRegion('banner', JSON.stringify([state.connected, o.warnings, o.update]), () => [
449
+ setRegion('banner', JSON.stringify([state.connected, o.warnings, o.update, o.permission?.level]), () => [
423
450
  !state.connected ? h('div', {class: 'banner', role: 'alert', text: 'Mất kết nối tới bridge. Dữ liệu bên dưới có thể đã cũ.'}) : null,
424
451
  ...(o.warnings || []).map(text => h('div', {class: 'banner warn', role: 'alert', text})),
452
+ o.permission && (o.permission.level === 'full' || o.permission.level === 'auto') ? h('div', {class: 'banner warn', role: 'alert', text: o.permission.level === 'full' ? 'Antigravity đang ở mức TOÀN QUYỀN: mọi thao tác được tự duyệt, kể cả ngoài thư mục dự án. Đổi lại ở Cài đặt.' : 'Antigravity đang ở chế độ Tự động: nó tự quyết định hành động. Đổi lại ở Cài đặt.'}) : null,
425
453
  o.update ? h('div', {class: 'banner info', role: 'status'}, `Có bản mới ${o.update.package} ${o.update.latest} (đang chạy ${o.update.current}). Dừng server (Ctrl+C) rồi chạy `, h('code', {text: o.update.command})) : null].filter(Boolean));
426
454
  if (regions.view === 'tasks') {
427
455
  setRegion('stats', JSON.stringify([o.tasks.map(t => [t.task_id, t.status]), o.cli, o.delegation, o.server.accepting_tasks]), () => statsView(o));
@@ -434,7 +462,7 @@ function patch() {
434
462
  if (!rebuilt) patchLogs();
435
463
  } else {
436
464
  if (!state.models && !state.modelsLoading) loadModels();
437
- setRegion('settings', JSON.stringify([state.models, o.model, o.cli, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));
465
+ setRegion('settings', JSON.stringify([state.models, o.model, o.permission, o.cli, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));
438
466
  }
439
467
  tick();
440
468
  }