Allow three OAuth consent levels and cache agent permission checks
This commit is contained in:
1 parent
794274d31b
commit
daf9b458af
10 files changed
+245
-22
No files matched your search
@@ -150,3 +150,5 @@ MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审
|
|||||||
独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。
|
独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。
|
||||||
|
|
||||||
已授权的 OAuth 与个人令牌可在“我的连接”修改只读、草稿或直接写入权限和隐藏账户权限,验证当前密码后生效;不延长授权期限,实际变更会取消该连接未确认草稿。MCP `movement_create(operation=repay)` 支持信用卡等负债账户,并保留真正还款类型及优惠、编辑和撤销语义。详见 [连接权限、还款与账号删除更新](docs/update-connections-repayment-users-2026-10-05.md)。
|
已授权的 OAuth 与个人令牌可在“我的连接”修改只读、草稿或直接写入权限和隐藏账户权限,验证当前密码后生效;不延长授权期限,实际变更会取消该连接未确认草稿。MCP `movement_create(operation=repay)` 支持信用卡等负债账户,并保留真正还款类型及优惠、编辑和撤销语义。详见 [连接权限、还款与账号删除更新](docs/update-connections-repayment-users-2026-10-05.md)。
|
||||||
|
|
||||||
|
OAuth 授权页始终提供只读、草稿和直接写入三档,普通用户可明确选择最终权限。Agent 教程和 Codex 配置提示词要求连接时核对一次当前权限并缓存,发生重连、权限变更通知、授权错误或缓存不足时才再次检查。详见 [OAuth 三档选择与权限缓存](docs/update-oauth-permissions-2026-10-05.md)。
|
||||||
@@ -82,6 +82,7 @@ async function main() {
|
|||||||
'test/admin-integration.test.ts',
|
'test/admin-integration.test.ts',
|
||||||
'test/mcp.test.ts',
|
'test/mcp.test.ts',
|
||||||
'test/oauth-duration.test.ts',
|
'test/oauth-duration.test.ts',
|
||||||
|
'test/oauth-permissions.test.ts',
|
||||||
'test/database-migrations.test.ts',
|
'test/database-migrations.test.ts',
|
||||||
'test/connection-repayment-integration.test.ts',
|
'test/connection-repayment-integration.test.ts',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -155,8 +155,10 @@ export class AgentManagementController {
|
|||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@Get('authorizations/:id') pending(@Param('id') id: string) {
|
@Get('authorizations/:id') async pending(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
return this.oauth.pending(z.string().uuid().parse(id));
|
const result = await this.oauth.pending(z.string().uuid().parse(id));
|
||||||
|
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||||
|
return { ...result, canWrite: user.role !== 'readonly' };
|
||||||
}
|
}
|
||||||
@Post('authorizations/:id') async consent(
|
@Post('authorizations/:id') async consent(
|
||||||
@Req() r: UserRequest,
|
@Req() r: UserRequest,
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
name: client?.client_name,
|
name: client?.client_name,
|
||||||
redirectUri: p.redirectUri,
|
redirectUri: p.redirectUri,
|
||||||
scopes: p.scopes,
|
scopes: p.scopes,
|
||||||
|
availableScopes: [
|
||||||
|
'read',
|
||||||
|
'draft',
|
||||||
|
'write',
|
||||||
|
...p.scopes.filter((s: string) => s.startsWith('hidden_')),
|
||||||
|
],
|
||||||
resource: p.resource,
|
resource: p.resource,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -188,8 +194,14 @@ export class AgentOAuth implements OAuthServerProvider {
|
|||||||
allowed.some((s: string) => ['draft', 'write', 'hidden_write'].includes(s))
|
allowed.some((s: string) => ['draft', 'write', 'hidden_write'].includes(s))
|
||||||
)
|
)
|
||||||
throw new ForbiddenException('只读账号只能授予查询权限');
|
throw new ForbiddenException('只读账号只能授予查询权限');
|
||||||
if (allowed.some((scope: string) => !parameters.scopes.includes(scope)))
|
// The owner explicitly chooses the ordinary permission level on the consent page.
|
||||||
throw new BadRequestException('不能授予客户端未请求的权限');
|
// Hidden access still requires a corresponding client request and separate consent.
|
||||||
|
if (
|
||||||
|
allowed.some(
|
||||||
|
(scope: string) => scope.startsWith('hidden_') && !parameters.scopes.includes(scope),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new BadRequestException('不能授予客户端未请求的隐藏权限');
|
||||||
const code = secret();
|
const code = secret();
|
||||||
const changed = await this.db.agentAuthorization.updateMany({
|
const changed = await this.db.agentAuthorization.updateMany({
|
||||||
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
|
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
test(
|
||||||
|
'OAuth read-only requests offer all three ordinary levels; explicit owner consent determines token scopes',
|
||||||
|
{ skip: process.env.TEST_ISOLATED !== 'true' },
|
||||||
|
async () => {
|
||||||
|
const db = new PrismaClient(),
|
||||||
|
ids: string[] = [];
|
||||||
|
const base = process.env.TEST_API_URL!,
|
||||||
|
root = base.replace(/\/api$/, '');
|
||||||
|
const resource = process.env.MCP_PUBLIC_URL!,
|
||||||
|
redirect = 'http://127.0.0.1:47891/callback';
|
||||||
|
const clientId = randomUUID();
|
||||||
|
async function web(cookie: string, id: string, body?: unknown) {
|
||||||
|
const r = await fetch(base + '/agent/authorizations/' + id, {
|
||||||
|
method: body ? 'POST' : 'GET',
|
||||||
|
headers: {
|
||||||
|
Cookie: cookie,
|
||||||
|
Origin: process.env.WEB_ORIGIN!,
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: r.status, data: await r.json() };
|
||||||
|
}
|
||||||
|
async function request(scopes = ['read']) {
|
||||||
|
const id = randomUUID(),
|
||||||
|
verifier = randomBytes(32).toString('base64url');
|
||||||
|
await db.agentAuthorization.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
clientId,
|
||||||
|
expiresAt: new Date(Date.now() + 600000),
|
||||||
|
parameters: {
|
||||||
|
redirectUri: redirect,
|
||||||
|
resource,
|
||||||
|
scopes,
|
||||||
|
codeChallenge: createHash('sha256').update(verifier).digest('base64url'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id, verifier };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db.agentClient.create({
|
||||||
|
data: {
|
||||||
|
id: clientId,
|
||||||
|
metadata: {
|
||||||
|
client_id: clientId,
|
||||||
|
client_name: 'Three permissions regression',
|
||||||
|
redirect_uris: [redirect],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const role of ['user', 'readonly'] as const) {
|
||||||
|
const u = await db.user.create({
|
||||||
|
data: { username: 'oauth_levels_' + randomUUID(), passwordHash: 'unused', role },
|
||||||
|
});
|
||||||
|
ids.push(u.id);
|
||||||
|
const session = randomBytes(32).toString('hex');
|
||||||
|
await db.session.create({
|
||||||
|
data: {
|
||||||
|
id: createHash('sha256').update(session).digest('hex'),
|
||||||
|
userId: u.id,
|
||||||
|
expiresAt: new Date(Date.now() + 3600000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cookie = 'wp_session=' + session;
|
||||||
|
for (const level of ['read', 'draft', 'write']) {
|
||||||
|
const a = await request();
|
||||||
|
const preview = await web(cookie, a.id);
|
||||||
|
assert.equal(preview.status, 200);
|
||||||
|
assert.deepEqual(preview.data.scopes, ['read']);
|
||||||
|
assert.deepEqual(preview.data.availableScopes, ['read', 'draft', 'write']);
|
||||||
|
assert.equal(preview.data.canWrite, role !== 'readonly');
|
||||||
|
const selected = level === 'read' ? ['read'] : ['read', level];
|
||||||
|
const approved = await web(cookie, a.id, { approve: true, scopes: selected, days: 7 });
|
||||||
|
if (role === 'readonly' && level !== 'read') {
|
||||||
|
assert.equal(approved.status, 403);
|
||||||
|
assert.equal(
|
||||||
|
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: a.id } })).status,
|
||||||
|
'pending',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert.equal(approved.status, 201, JSON.stringify(approved.data));
|
||||||
|
const code = new URL(approved.data.redirect).searchParams.get('code')!;
|
||||||
|
const response = await fetch(root + '/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
code_verifier: a.verifier,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
resource,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const token = await response.json();
|
||||||
|
assert.equal(response.status, 200, JSON.stringify(token));
|
||||||
|
assert.equal(token.scope, selected.join(' '));
|
||||||
|
const row = await db.agentGrant.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
userId: u.id,
|
||||||
|
accessDigest: createHash('sha256').update(token.access_token).digest('hex'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(row.scopes, selected);
|
||||||
|
}
|
||||||
|
if (role === 'user') {
|
||||||
|
const noHidden = await request();
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await web(cookie, noHidden.id, {
|
||||||
|
approve: true,
|
||||||
|
scopes: ['read', 'write', 'hidden_read'],
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const requestedHidden = await request(['read', 'hidden_read', 'hidden_write']);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await web(cookie, requestedHidden.id, {
|
||||||
|
approve: true,
|
||||||
|
scopes: ['read', 'write', 'hidden_read', 'hidden_write'],
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
const defaults = await request();
|
||||||
|
assert.equal((await web(cookie, defaults.id, { approve: true })).status, 201);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: defaults.id } }))
|
||||||
|
.parameters &&
|
||||||
|
(
|
||||||
|
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: defaults.id } }))
|
||||||
|
.parameters as any
|
||||||
|
).scopes,
|
||||||
|
['read'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await db.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
await db.agentClient.deleteMany({ where: { id: clientId } });
|
||||||
|
await db.$disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -57,7 +57,7 @@ function instructions(url: string) {
|
|||||||
'以下是配置 MCP 连接后的操作说明,不会自动添加连接或授予工具。若本会话没有 WorthPath 工具,请先告知我,并指导我在客户端配置连接;不要仅凭网址或聊天中的令牌声称已连接。',
|
'以下是配置 MCP 连接后的操作说明,不会自动添加连接或授予工具。若本会话没有 WorthPath 工具,请先告知我,并指导我在客户端配置连接;不要仅凭网址或聊天中的令牌声称已连接。',
|
||||||
'请使用已连接的 WorthPath MCP 服务处理我的资产与负债,服务地址:' + url,
|
'请使用已连接的 WorthPath MCP 服务处理我的资产与负债,服务地址:' + url,
|
||||||
'接入使用 Streamable HTTP。支持 OAuth 的客户端使用该地址发现授权服务,并由我在 WorthPath 网页登录授权;个人令牌仅用于明确支持 Bearer 认证的客户端,按其文档配置环境变量或凭据存储。不要假设客户端有自定义请求头界面,不要让我把密码或令牌粘贴到对话中。localhost 仅适用于 MCP 请求在 WorthPath 所在电脑执行的客户端;云端客户端需要可访问的 HTTPS 地址。',
|
'接入使用 Streamable HTTP。支持 OAuth 的客户端使用该地址发现授权服务,并由我在 WorthPath 网页登录授权;个人令牌仅用于明确支持 Bearer 认证的客户端,按其文档配置环境变量或凭据存储。不要假设客户端有自定义请求头界面,不要让我把密码或令牌粘贴到对话中。localhost 仅适用于 MCP 请求在 WorthPath 所在电脑执行的客户端;云端客户端需要可访问的 HTTPS 地址。',
|
||||||
'先 tools/list 发现工具,调用 connection_info 核对权限:read 只读,draft 创建待网页确认的草稿,write 直接普通写入。隐藏账户读写以连接附加授权为准。',
|
'连接时先 tools/list 发现真实工具,再调用一次 connection_info 核对当前 resource、permission、scopes 和隐藏账户读写权限,并在本次连接中缓存结果。权限可能被我在网页修改,以服务器返回的当前权限为准,不把以前申请或聊天中的权限当作永久授权。read 只读,draft 创建待网页确认的草稿,write 直接普通写入。无需每次查询或写入前重复检查;仅在重新连接或授权、我告知权限已变更、遇到权限/授权错误,或当前缓存不足以判断能否执行任务时,再检查一次并更新缓存。不得自动扩大权限或绕过确认;不匹配预期时报告实际差异。',
|
||||||
'查询:用 positions_list 搜索对象;需要选定对象且遇到同名时先让我选择稳定 UUID;按 limit/offset 或 cursor 完整翻页。金额和克数使用十进制字符串,业务时间是 UTC+8 的 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm。不同币种不能直接相加;明确折算币种、汇率日期、统计纳入范围及缺失汇率或估值。不擅自排除测试项目或其他项目;若提供剔除后的参考汇总,同时说明系统实际汇总与剔除项,不修改纳入设置。',
|
'查询:用 positions_list 搜索对象;需要选定对象且遇到同名时先让我选择稳定 UUID;按 limit/offset 或 cursor 完整翻页。金额和克数使用十进制字符串,业务时间是 UTC+8 的 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm。不同币种不能直接相加;明确折算币种、汇率日期、统计纳入范围及缺失汇率或估值。不擅自排除测试项目或其他项目;若提供剔除后的参考汇总,同时说明系统实际汇总与剔除项,不修改纳入设置。',
|
||||||
'写入:先 state_get 取得 state,使用 expectedState 与唯一 idempotencyKey 调用所需工具。网络重试保持键和全部参数一致;状态冲突则重新查询并换键。不要把估值更新、转账、还款或负债变化互相替代。',
|
'写入:先 state_get 取得 state,使用 expectedState 与唯一 idempotencyKey 调用所需工具。网络重试保持键和全部参数一致;状态冲突则重新查询并换键。不要把估值更新、转账、还款或负债变化互相替代。',
|
||||||
'若返回 pending,把 confirmationUrl 给我,由我在网页审阅并确认;用 operation_get 轮询最终结果,不能用 confirmed=true 代替人类确认,也不能把草稿称为已完成。',
|
'若返回 pending,把 confirmationUrl 给我,由我在网页审阅并确认;用 operation_get 轮询最终结果,不能用 confirmed=true 代替人类确认,也不能把草稿称为已完成。',
|
||||||
@@ -97,7 +97,7 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden
|
|||||||
selected +
|
selected +
|
||||||
' --oauth-client-registration dcr 发起网页登录。我会自行登录并确认权限。等待回调时保留登录进程;需要等待我完成网页步骤时,明确告诉我当前状态。',
|
' --oauth-client-registration dcr 发起网页登录。我会自行登录并确认权限。等待回调时保留登录进程;需要等待我完成网页步骤时,明确告诉我当前状态。',
|
||||||
'若授权链接包含 read,draft,write,hidden_read,hidden_write 等较宽范围,这是客户端候选范围,不代表最终授权。WorthPath 网页支持收窄,服务端按网页选择发行令牌。打开正确 WorthPath 资源的授权页供我审阅;由我选择本次指定的权限等级、隐藏账户读写选项和授权期限并确认。不要仅因候选范围较宽就终止登录;最终以 connection_info 返回的实际权限为准。',
|
'若授权链接包含 read,draft,write,hidden_read,hidden_write 等较宽范围,这是客户端候选范围,不代表最终授权。WorthPath 网页支持收窄,服务端按网页选择发行令牌。打开正确 WorthPath 资源的授权页供我审阅;由我选择本次指定的权限等级、隐藏账户读写选项和授权期限并确认。不要仅因候选范围较宽就终止登录;最终以 connection_info 返回的实际权限为准。',
|
||||||
'分别验证连接配置正确且启用、OAuth 登录成功、真实 MCP 工具调用成功,三者不能互相替代。先通过 tools/list 或客户端暴露的工具目录发现真实工具,再调用 connection_info。resource 必须为 ' +
|
'分别验证连接配置正确且启用、OAuth 登录成功、真实 MCP 工具调用成功,三者不能互相替代。首次连接通过 tools/list 或客户端暴露的工具目录发现真实工具,再调用一次 connection_info 核对并缓存当前授权。resource 必须为 ' +
|
||||||
url +
|
url +
|
||||||
',permission 必须为 ' +
|
',permission 必须为 ' +
|
||||||
level +
|
level +
|
||||||
@@ -107,9 +107,9 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden
|
|||||||
hiddenRead +
|
hiddenRead +
|
||||||
'、writeHidden=' +
|
'、writeHidden=' +
|
||||||
requestedHiddenWrite +
|
requestedHiddenWrite +
|
||||||
'。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。',
|
'。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。此处申请范围用于首次配置验收;之后权限可能由我在网页更改,按当前服务器授权判断可执行范围。无需每次操作前重复检查,仅在重新连接/授权、我告知变更、权限错误或缓存不足时再检查一次。',
|
||||||
'保存跨对话使用规则:在实际 CODEX_HOME(默认 ~/.codex)的全局指令中,先检查 AGENTS.override.md 与 AGENTS.md 的加载优先级,在会被加载的文件中新增或更新 <!-- worthpath-mcp-preference:start --> 与 <!-- worthpath-mcp-preference:end --> 标记块。保留已有内容,只更新此块;不要创建会遮蔽其他全局指令的 override 文件,不重复添加规则。',
|
'保存跨对话使用规则:在实际 CODEX_HOME(默认 ~/.codex)的全局指令中,先检查 AGENTS.override.md 与 AGENTS.md 的加载优先级,在会被加载的文件中新增或更新 <!-- worthpath-mcp-preference:start --> 与 <!-- worthpath-mcp-preference:end --> 标记块。保留已有内容,只更新此块;不要创建会遮蔽其他全局指令的 override 文件,不重复添加规则。',
|
||||||
'全局规则应保存本次服务地址、申请的权限等级、scopes 和隐藏账户读写选项,以及下面的查询和写入操作规则。每个新会话先发现真实工具,再通过 connection_info 核对资源及本次授权,若不符停止使用并报告差异。用户提出个人余额、账户、资产、负债、净资产、历史、转账、借贷、还款或其他 WorthPath 任务时主动优先调用 worthpath MCP,不要求重复指定,不把旧聊天余额当作当前数据。这项偏好仅决定工具选择;只有用户提出具体写入任务后才执行,不自动授权账目修改。',
|
'全局规则应保存本次服务地址、申请的权限等级、scopes 和隐藏账户读写选项,以及下面的查询和写入操作规则。每个新会话连接时先发现真实工具,再调用一次 connection_info 核对并缓存当前资源、权限、scopes 与隐藏读写;与本次明确预期不符时停止使用并报告差异。权限可能在网页被更改,旧申请和旧聊天不代表当前授权。无需每次操作前重复检查;只在重新连接/授权、用户告知权限变更、权限错误或缓存不足时再检查一次并更新缓存。用户提出个人余额、账户、资产、负债、净资产、历史、转账、借贷、还款或其他 WorthPath 任务时主动优先调用 worthpath MCP,不要求重复指定,不把旧聊天余额当作当前数据。这项偏好仅决定工具选择;只有用户提出具体写入任务后才执行,不自动授权账目修改。',
|
||||||
'工具未加载、服务不可达或授权失效时如实说明,必要时提示重新授权、重启客户端或新建本机会话,不擅自改用 UI/API 写入。不记录密码、令牌、账户余额或其他财务明细到全局指令中。保存后验证文件内容与加载条件,说明规则适用于这台电脑使用同一 Codex 配置的本机会话,不声称自动适用于其他电脑或云端会话。',
|
'工具未加载、服务不可达或授权失效时如实说明,必要时提示重新授权、重启客户端或新建本机会话,不擅自改用 UI/API 写入。不记录密码、令牌、账户余额或其他财务明细到全局指令中。保存后验证文件内容与加载条件,说明规则适用于这台电脑使用同一 Codex 配置的本机会话,不声称自动适用于其他电脑或云端会话。',
|
||||||
...instructions(url).split('\n\n').slice(3),
|
...instructions(url).split('\n\n').slice(3),
|
||||||
'完成后简要报告连接配置、OAuth、真实工具验证和全局规则保存的实际结果及剩余步骤。在我提出具体账目任务前,不修改任何数据。',
|
'完成后简要报告连接配置、OAuth、真实工具验证和全局规则保存的实际结果及剩余步骤。在我提出具体账目任务前,不修改任何数据。',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type Consent = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
|
availableScopes: string[];
|
||||||
|
canWrite: boolean;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
resource: string;
|
resource: string;
|
||||||
};
|
};
|
||||||
@@ -75,14 +77,20 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
|||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [id, reload]);
|
}, [id, reload]);
|
||||||
const selected =
|
const selected = consent
|
||||||
consent?.scopes.filter(
|
? [
|
||||||
(s) =>
|
'read',
|
||||||
s === 'read' ||
|
...(level !== 'read' && consent.canWrite ? [level] : []),
|
||||||
s === level ||
|
...(hiddenRead && consent.scopes.includes('hidden_read') ? ['hidden_read'] : []),
|
||||||
(s === 'hidden_read' && hiddenRead) ||
|
...(hiddenWrite &&
|
||||||
(s === 'hidden_write' && hiddenWrite && hiddenRead && level !== 'read'),
|
hiddenRead &&
|
||||||
) || [];
|
level !== 'read' &&
|
||||||
|
consent.canWrite &&
|
||||||
|
consent.scopes.includes('hidden_write')
|
||||||
|
? ['hidden_write']
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
async function decide(approve: boolean) {
|
async function decide(approve: boolean) {
|
||||||
if (!consent || busy) return;
|
if (!consent || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -116,12 +124,12 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
|||||||
['draft', '草稿修改', '提出修改,由你逐项确认后执行。'],
|
['draft', '草稿修改', '提出修改,由你逐项确认后执行。'],
|
||||||
['write', '直接写入', '普通账目修改立即执行,无需逐次确认。'],
|
['write', '直接写入', '普通账目修改立即执行,无需逐次确认。'],
|
||||||
]
|
]
|
||||||
.filter(([key]) => consent.scopes.includes(key))
|
.filter(([key]) => consent.availableScopes.includes(key))
|
||||||
.map(([key, title, detail]) => (
|
.map(([key, title, detail]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
className={level === key ? 'permission-choice selected' : 'permission-choice'}
|
className={level === key ? 'permission-choice selected' : 'permission-choice'}
|
||||||
disabled={busy}
|
disabled={busy || (key !== 'read' && !consent.canWrite)}
|
||||||
aria-pressed={level === key}
|
aria-pressed={level === key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLevel(key);
|
setLevel(key);
|
||||||
@@ -133,6 +141,12 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{level === 'write' && (
|
||||||
|
<p className="agent-warning">
|
||||||
|
直接写入会立即修改账目,无需逐次网页确认。请仅授予你信任的助手。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!consent.canWrite && <p className="muted">你的账号为只读用户,只能授予查询权限。</p>}
|
||||||
<label className="agent-duration">
|
<label className="agent-duration">
|
||||||
授权有效期
|
授权有效期
|
||||||
<select value={days} disabled={busy} onChange={(e) => setDays(e.target.value)}>
|
<select value={days} disabled={busy} onChange={(e) => setDays(e.target.value)}>
|
||||||
@@ -170,7 +184,7 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
|||||||
<label className="check-line">
|
<label className="check-line">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={busy || !hiddenRead || level === 'read'}
|
disabled={busy || !consent.canWrite || !hiddenRead || level === 'read'}
|
||||||
checked={hiddenWrite}
|
checked={hiddenWrite}
|
||||||
onChange={(e) => setHiddenWrite(e.target.checked)}
|
onChange={(e) => setHiddenWrite(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
@@ -200,12 +214,14 @@ export function AgentAuthorization({ id }: { id: string | null }) {
|
|||||||
<dd>{consent.resource}</dd>
|
<dd>{consent.resource}</dd>
|
||||||
<dt>回调地址</dt>
|
<dt>回调地址</dt>
|
||||||
<dd>{consent.redirectUri}</dd>
|
<dd>{consent.redirectUri}</dd>
|
||||||
<dt>候选权限</dt>
|
<dt>客户端申请权限</dt>
|
||||||
<dd>{consent.scopes.join(', ')}</dd>
|
<dd>{consent.scopes.join(', ')}</dd>
|
||||||
<dt>最终权限代码</dt>
|
<dt>最终权限代码</dt>
|
||||||
<dd>{selected.join(', ')}</dd>
|
<dd>{selected.join(', ')}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<p>客户端请求的是候选范围;最终仅授予上方选中的权限。只批准你正在连接的客户端。</p>
|
<p>
|
||||||
|
普通权限等级由你在此选择,可以高于客户端申请范围;隐藏访问仅限客户端已申请的范围。最终仅授予上方明确选中的权限。只批准你正在连接并信任的客户端。
|
||||||
|
</p>
|
||||||
</details>
|
</details>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="secondary" disabled={busy} onClick={() => void decide(false)}>
|
<button className="secondary" disabled={busy} onClick={() => void decide(false)}>
|
||||||
|
|||||||
+5
-1
@@ -126,7 +126,7 @@ cd E:\WorthPath
|
|||||||
|
|
||||||
### 客户端请求范围与最终授权
|
### 客户端请求范围与最终授权
|
||||||
|
|
||||||
本机 Codex CLI 0.160.0 实测:显式 login --scopes read,draft 只请求两项;不指定 scopes 的默认登录请求全部五项。请求范围是候选权限,不是已经授予的权限。授权页可选择只读、草稿或直接写入;隐藏读写默认关闭。页面分别展示候选和最终范围,服务端验证选择是请求子集,再用所选 scopes 覆盖待处理参数并发行令牌。
|
本机 Codex CLI 0.160.0 实测:显式 login --scopes read,draft 只请求两项;不指定 scopes 的默认登录请求全部五项。请求范围是候选权限,不是已经授予的权限。授权页可选择只读、草稿或直接写入;隐藏读写默认关闭。页面分别展示候选和最终范围,2026-10-05 起,普通权限等级由用户在网页明确选择,可高于客户端申请范围;隐藏权限仍限于客户端申请范围。服务端用最终选择的 scopes 覆盖待处理参数并发行令牌。
|
||||||
|
|
||||||
申请草稿模式时选择“草稿修改(网页确认)”,不勾选隐藏账户;最终将授予应显示 read,draft。授权后用 connection_info 核对实际 scopes 仅有两项、readHidden/writeHidden 均 false。不要仅因默认请求包含全部候选权限就停止登录,最终批准仍由用户完成。
|
申请草稿模式时选择“草稿修改(网页确认)”,不勾选隐藏账户;最终将授予应显示 read,draft。授权后用 connection_info 核对实际 scopes 仅有两项、readHidden/writeHidden 均 false。不要仅因默认请求包含全部候选权限就停止登录,最终批准仍由用户完成。
|
||||||
|
|
||||||
@@ -153,3 +153,7 @@ NETWORK_ALLOW_HTTP=true 时支持配置的 HTTP MCP 服务和网页审核地址
|
|||||||
`movement_create` 的 repay 从资产账户付款,目标支持借入债务和信用卡等负债账户。账户超额还款记为溢缴存款;独立债务仍拒绝超额还款。原币本金用 amount/received,fee 为负表示优惠、为正表示手续费;保留 repay 类型,双边余额及历史在同一事务提交,记录可编辑和撤销。
|
`movement_create` 的 repay 从资产账户付款,目标支持借入债务和信用卡等负债账户。账户超额还款记为溢缴存款;独立债务仍拒绝超额还款。原币本金用 amount/received,fee 为负表示优惠、为正表示手续费;保留 repay 类型,双边余额及历史在同一事务提交,记录可编辑和撤销。
|
||||||
|
|
||||||
本次通过 57 项单元测试、9 项隔离 MySQL/REST/官方 SDK 集成测试、类型检查及生产构建;网页验证权限编辑表单、还款草稿预览、默认 admin 删除入口与余额颜色。详见 [更新验收](update-connections-repayment-users-2026-10-05.md)。
|
本次通过 57 项单元测试、9 项隔离 MySQL/REST/官方 SDK 集成测试、类型检查及生产构建;网页验证权限编辑表单、还款草稿预览、默认 admin 删除入口与余额颜色。详见 [更新验收](update-connections-repayment-users-2026-10-05.md)。
|
||||||
|
|
||||||
|
### OAuth 三档选择与助手权限缓存
|
||||||
|
|
||||||
|
OAuth 即使只申请 read,网页也提供只读、草稿与直接写入,默认只读,由用户确认最终等级;只读用户不能授予写入,隐藏访问仍需申请并勾选。助手在连接时检查一次 connection_info 并缓存;权限可能在网页更改,重新连接/授权、用户通知变更、权限错误或缓存不足时再检查一次,无需逐次操作重复检查。服务端继续逐请求验证实际权限。教程、配置提示词及其保存规则均已同步,见 [本次更新](update-oauth-permissions-2026-10-05.md)。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# OAuth 三档权限选择与权限缓存提示词
|
||||||
|
|
||||||
|
完成时间:2026-10-05 21:34(UTC+8)。
|
||||||
|
|
||||||
|
OAuth 授权页始终提供只读查询、草稿修改和直接写入三个等级,默认只读。即使客户端只申请 read,普通用户也可以在网页主动选择 draft 或 write,服务端按明确确认的范围发行令牌,并在 token.scope 返回实际范围。选择直接写入后显示即时写入说明,不自动替用户选择或确认。
|
||||||
|
|
||||||
|
隐藏读写仍要求客户端申请对应范围并由用户勾选。只读系统用户能看到三档说明,但草稿和直接写入不可选;服务端也拒绝授予写入。刷新令牌仍不能自行扩大范围,授权有效期和撤销规则保留。
|
||||||
|
|
||||||
|
授权预览新增 availableScopes 和 canWrite,scopes 保留客户端原申请,页面区分“客户端申请权限”与“最终权限代码”。三档权限互斥,draft/write 不同时授予。
|
||||||
|
|
||||||
|
## 提示词
|
||||||
|
|
||||||
|
页面的“Agent 使用教程”和“Codex 配置提示词”现在明确:
|
||||||
|
|
||||||
|
- 连接时发现真实工具,调用一次 connection_info 核对当前资源、权限、scopes 与隐藏读写,并在本连接中缓存。
|
||||||
|
- 权限可能在网页更改,旧申请或聊天中的权限不代表当前授权。
|
||||||
|
- 不需要每次查询或写入前检查;重新连接/授权、用户告知变更、权限错误或缓存不足以判断可执行范围时,再检查一次并更新缓存。
|
||||||
|
- 初次配置仍核对本次预期范围;不匹配时报告差异,不擅自申请更高权限或绕过真实 pending 的人工确认。
|
||||||
|
- 缓存是助手的查询策略;服务端每次请求仍检查实际授权、有效期、撤销状态和用户权限。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
7 项隔离 MySQL/HTTP/官方 SDK 集成测试通过,包括客户端仅申请 read 时分别确认三档权限、令牌 scope 正确、默认只读、只读用户拒绝写入、隐藏权限约束、原有 OAuth/刷新与业务回归。前后端生产构建通过。
|
||||||
|
|
||||||
|
独立临时数据库网页验收显示三个可选按钮,选择直接写入后最终授权与说明同步变化。未点击真实用户 OAuth 确认,也未改变线上连接或账目。清理测试库、服务和一次性脚本;保留可重跑回归测试。截图保存在仓库外,docs 无图片。本次没有数据库结构变更。
|
||||||
|
|
||||||
|
本地提交,不推送、不部署。已有 compose.yaml 和 docker.md 改动保留。
|
||||||
@@ -103,3 +103,5 @@
|
|||||||
- ~~通过MCP 对账号还款时 确定时出现请选择有效的资产账户和对应借入或借出债务。本批修改未执行,请刷新后重新审阅。~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
|
- ~~通过MCP 对账号还款时 确定时出现请选择有效的资产账户和对应借入或借出债务。本批修改未执行,请刷新后重新审阅。~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
|
||||||
- ~~删除账号功能,只能删除admin默认的这个账号,对其他账号无法被删除~~ — 已完成并验证:2026-10-05 20:23(UTC+8);按用户澄清:仅默认 admin 可删除,其他账号禁止删除,见 docs/update-connections-repayment-users-2026-10-05.md
|
- ~~删除账号功能,只能删除admin默认的这个账号,对其他账号无法被删除~~ — 已完成并验证:2026-10-05 20:23(UTC+8);按用户澄清:仅默认 admin 可删除,其他账号禁止删除,见 docs/update-connections-repayment-users-2026-10-05.md
|
||||||
- ~~余额显示绿色,欠款为红色~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
|
- ~~余额显示绿色,欠款为红色~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
|
||||||
|
- ~~使用OAuth的进行连接时应该可以设置三种权限,但是现在授权页只有一种~~ — 已完成并验证:2026-10-05 21:34(UTC+8),见 docs/update-oauth-permissions-2026-10-05.md
|
||||||
|
- ~~修改 Agent 提示词:连接时检查一次当前权限,权限可能变更,仅在需要时重新检查~~ — 已完成:2026-10-05 21:34(UTC+8),见 docs/update-oauth-permissions-2026-10-05.md
|
||||||
Reference in new issue
Block a user