合并 origin/master 至 feat/agc-use-ui-design

- 修复 ui-editor 调用 LLM 与实际配置不一致 (#235)
- 本地资源注册与后台表查询 (#232)
- 清除策划 agent 不可达分支 (#234)
- Direct 首轮带上本轮附件的项目路径映射 (#223)
- 修复项目页布局高度 (#228)
- 修复 AGC Windows ACL 提权与文件访问边界 (#211)
- 批准 GDD 回填做游戏入口 (#210)
- 优化 Game Agent 资源画布展示与默认缩放 (#208)

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-09-01 16:45:15 +08:00
74 changed files with 10339 additions and 1223 deletions
@@ -2,7 +2,7 @@
## 需求落点
- 后台“总览”页的表统计仍保留,只把每张表的表名改成可点击跳转到 `#tables?table=<name>`
- 新增独立 `#tables` 页承载表选择、关键词搜索、JSON filters、limit、行详情弹窗
- 新增独立 `#tables` 页承载表选择、关键词搜索、结构化字段筛选、limit、行详情弹窗(详情内保留字段复制,列头漏斗按钮可按列添加条件;每条条件可勾选启用或停用,停用时保留字段和值;`in` / `notIn` 使用逐项值标签,支持粘贴多行值,逗号不再作为隐式分隔符)
## 后端实现要点
- 新增只读接口:
@@ -13,12 +13,13 @@
- `search` / `filters` 不进入 SQL 字符串:
- SQL 只负责 `SELECT * FROM {table_name} LIMIT {limit}`
- 返回后在 api-server 内存中过滤
- `filters` 接受 JSON object,按列名匹配;非 object 直接 400
- `filters` 接受两种 JSON 形式:object(列名到等值,如 `{"user_id":"u1"}`)与条件数组(如 `[{"column":"points","op":"gt","value":"5"}]`,运算符含 `eq``ne``gt``gte``lt``lte``contains``notContains``startsWith``endsWith``in``notIn``isEmpty``isNotEmpty`,允许同列多条件,条件间为 AND;非 object 且非数组直接 400,未知运算符或 value 形态不匹配也 400。数组中的 `eq` / `ne` 和其他标量运算符一样必须提供 `value`;显式空值判断使用 `isEmpty` / `isNotEmpty`
- SpacetimeDB HTTP SQL 返回可能是 statement array + rows,解析时要兼容这一层结构。
## 前端实现要点
- `adminRoutes` 必须新增 `tables``AdminShell.routeIcons` 也要同步覆盖。
- `AdminApp` 需要显式渲染 `AdminDatabaseTablesPage`
- 预览表格数据行直接点击(或行自身聚焦后按 Enter / Space)打开详情,行内按钮 / 输入控件的键盘操作不冒泡打开详情;详情按钮不单独占列。详情字段仅提供复制操作,成功、剪贴板失败和空字段复制都使用右下角自动消失的 Toast,JSON 预览与平台亮色 / 暗色主题保持一致,表头保持单行并在空间不足时省略显示。表单和标题区查询按钮共用筛选完整性校验。
- worktree 下可能没有本地 `node_modules/typescript/bin/tsc`,而根目录有依赖;在验证前可以临时把根目录 `node_modules` 软链到 worktree 再执行 `npm run admin-web:typecheck`,验证后删除软链,避免污染 git 状态。
## 验证结果
@@ -26,4 +27,4 @@
- `cargo fmt --manifest-path Cargo.toml -p api-server -p shared-contracts --check` 通过。
- `npm run admin-web:typecheck` 通过。
- `npm run admin-web:build` 通过。
- `npm run check:encoding` 通过。
- `npm run check:encoding` 通过。
@@ -8,6 +8,7 @@ import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes';
import {
AdminDatabaseTablesPage,
resolveAdminDatabaseUserReference,
@@ -23,7 +24,10 @@ vi.mock('../api/adminApiClient', () => ({
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
AdminUserReferenceButton: ({
userId,
publicUserCode,
}: {
userId?: string;
publicUserCode?: string;
}) => (
@@ -68,6 +72,7 @@ const referralRows = [
];
beforeEach(() => {
vi.clearAllMocks();
window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
fetchErrors: [],
@@ -124,7 +129,10 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可
),
).toBeTruthy();
await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件');
await user.type(
screen.getByRole('textbox', { name: '关键词' }),
'未执行条件',
);
await user.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => {
@@ -141,6 +149,53 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可
});
});
test('后台表查询页不会让旧表请求覆盖新表结果', async () => {
const user = userEvent.setup();
let resolveFirstRequest!: (response: AdminDatabaseTableRowsResponse) => void;
const firstRequest = new Promise<AdminDatabaseTableRowsResponse>(
(resolve) => {
resolveFirstRequest = resolve;
},
);
const oldTableResponse = {
columns: ['id'],
limit: 100,
page: 1,
rows: [{ cells: { id: 'old-row' }, raw: ['old-row'] }],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
} satisfies AdminDatabaseTableRowsResponse;
const newTableResponse = {
...oldTableResponse,
rows: [{ cells: { id: 'new-row' }, raw: ['new-row'] }],
tableName: 'profile_wallet',
} satisfies AdminDatabaseTableRowsResponse;
vi.mocked(getAdminDatabaseTables).mockResolvedValueOnce({
fetchErrors: [],
tables: ['profile_referral_relation', 'profile_wallet'],
});
vi.mocked(getAdminDatabaseTableRows)
.mockImplementationOnce(() => firstRequest)
.mockResolvedValueOnce(newTableResponse);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const tableSelect = await screen.findByRole('combobox');
await user.selectOptions(tableSelect, 'profile_wallet');
expect(await screen.findByText('new-row')).toBeTruthy();
resolveFirstRequest(oldTableResponse);
await waitFor(() => {
expect(screen.queryByText('old-row')).toBeNull();
});
expect(screen.getByText('new-row')).toBeTruthy();
});
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
const user = userEvent.setup();
const { container } = render(
@@ -165,7 +220,9 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'),
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
).toBe(
'原始字段名:invitee_user_id。被邀请人的用户标识。点击列名可在正序、倒序和不排序之间循环切换。 当前状态:不排序。',
);
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
@@ -219,6 +276,88 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序
);
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: referralRows,
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: undefined,
sortDirection: undefined,
}),
);
expect(
screen
.getAllByRole('columnheader', { name: '邀请人ID' })
.filter((header) => header.textContent?.trim() === '邀请人ID')
.some((header) => header.getAttribute('aria-sort') === 'none'),
).toBe(true);
});
});
test('后台表查询页行详情将对象值以语法高亮 JSON 预览', async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['id', 'metadata'],
limit: 100,
page: 1,
rows: [
{
cells: {
id: 'row-1',
metadata: { enabled: true, count: 2, label: 'demo' },
},
raw: ['row-1', { enabled: true, count: 2, label: 'demo' }],
},
],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
});
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('row-1');
expect(screen.queryByRole('button', { name: /^详情$/ })).toBeNull();
await user.click(screen.getByRole('row', { name: /row-1/ }));
const jsonPreview = document.querySelector('pre.admin-json-preview');
expect(jsonPreview).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-key')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-boolean')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-number')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-string')).toBeTruthy();
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(2);
await user.click(screen.getByRole('button', { name: '复制元数据' }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
JSON.stringify({ enabled: true, count: 2, label: 'demo' }, null, 2),
);
});
expect(screen.getByRole('status').textContent).toBe('已复制 元数据');
});
test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => {
@@ -227,11 +366,28 @@ test('数据库用户字段显示查看按钮且点击不会打开行详情', as
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', { name: '查看用户 u-b' });
const userButton = await screen.findByRole('button', {
name: '查看用户 u-b',
});
await user.click(userButton);
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户查看按钮上的键盘操作不会同时打开行详情', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', {
name: '查看用户 u-b',
});
userButton.focus();
await user.keyboard('{Enter}');
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => {
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'),
@@ -250,13 +406,254 @@ test('数据库用户字段识别会排除后台操作者与合成邀请码字
resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'),
resolveAdminDatabaseUserReference(
'profile_wallet',
'user_id',
'admin:root',
),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'),
).toBeNull();
});
test('后台表查询页字段条件构建后透传 filters 并支持列表值', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByRole('button', { name: '添加条件' }));
expect(screen.getAllByRole('columnheader', { name: '字段' })).toHaveLength(1);
expect(screen.getAllByRole('columnheader', { name: '条件' })).toHaveLength(1);
expect(screen.getAllByRole('columnheader', { name: '值' })).toHaveLength(1);
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 1 字段' }),
'invite_code',
);
await user.type(
screen.getByRole('textbox', { name: '条件 1 值' }),
'INV-1001',
);
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 字段' }),
'inviter_user_id',
);
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 运算符' }),
'in',
);
expect(screen.getByLabelText('条件 2 值列表').className).toContain(
'admin-database-filter-list-editor',
);
await user.type(
screen.getByRole('textbox', { name: '条件 2 值输入' }),
'u-a,b',
);
await user.click(screen.getByRole('button', { name: '添加条件 2 的值' }));
await user.type(
screen.getByRole('textbox', { name: '条件 2 值输入' }),
'u-c',
);
await user.click(screen.getByRole('button', { name: '添加条件 2 的值' }));
expect(
screen.getByText('u-a,b').closest('.admin-database-filter-values-row'),
).toBeTruthy();
expect(
screen
.getAllByText('u-c')
.some((element) => element.closest('.admin-database-filter-values-row')),
).toBe(true);
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invite_code', op: 'eq', value: 'INV-1001' },
{
column: 'inviter_user_id',
op: 'in',
value: ['u-a,b', 'u-c'],
},
]),
page: 1,
}),
);
});
});
test('后台表查询页可以勾选或取消勾选条件来切换筛选组合', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 1 字段' }),
'invite_code',
);
await user.type(
screen.getByRole('textbox', { name: '条件 1 值' }),
'INV-1001',
);
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 字段' }),
'inviter_user_id',
);
await user.type(screen.getByRole('textbox', { name: '条件 2 值' }), 'u-a');
await user.click(screen.getByRole('checkbox', { name: '启用条件 2' }));
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invite_code', op: 'eq', value: 'INV-1001' },
]),
page: 1,
}),
);
});
});
test('后台表查询页会提示未完成的启用条件而不是静默忽略', async () => {
const user = userEvent.setup();
const rowsRequest = vi.mocked(getAdminDatabaseTableRows);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
rowsRequest.mockClear();
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.click(getFormSubmitQueryButton());
expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy();
expect(rowsRequest).not.toHaveBeenCalled();
});
test('后台表查询页顶部查询按钮会提示未完成的启用条件而不是静默忽略', async () => {
const user = userEvent.setup();
const rowsRequest = vi.mocked(getAdminDatabaseTableRows);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
rowsRequest.mockClear();
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.click(getHeadingQueryButton());
expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy();
expect(rowsRequest).not.toHaveBeenCalled();
});
test('后台表查询页列头筛选按钮会带字段添加条件', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(
screen.getByRole('button', { name: '按被邀请人ID添加条件' }),
);
const columnSelect = screen.getByRole('combobox', {
name: '条件 1 字段',
}) as HTMLSelectElement;
expect(columnSelect.value).toBe('invitee_user_id');
await user.type(screen.getByRole('textbox', { name: '条件 1 值' }), 'u-b');
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invitee_user_id', op: 'eq', value: 'u-b' },
]),
page: 1,
}),
);
});
});
test('后台表查询页行详情仅保留复制操作', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByText('INV-1001').closest('tr')!);
expect(await screen.findByRole('dialog')).toBeTruthy();
expect(screen.queryByRole('button', { name: '按邀请码筛选' })).toBeNull();
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(4);
});
test('后台表查询页空字段详情复制会明确提示无法复制空值', async () => {
const user = userEvent.setup();
const writeText = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['id', 'deleted_at'],
limit: 100,
page: 1,
rows: [
{
cells: {
id: 'row-null',
deleted_at: null,
},
raw: ['row-null', null],
},
],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
});
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('row-null');
await user.click(screen.getByText('row-null').closest('tr')!);
await user.click(screen.getByRole('button', { name: '复制删除时间' }));
expect(screen.getByRole('alert').textContent).toBe('该字段为空,无法复制');
expect(writeText).not.toHaveBeenCalled();
});
function getFormSubmitQueryButton() {
const buttons = screen.getAllByRole('button', { name: '查询' });
return buttons[buttons.length - 1]!;
}
function getHeadingQueryButton() {
const buttons = screen.getAllByRole('button', { name: '查询' });
return buttons[0]!;
}
function readFirstColumnValues(container: HTMLElement) {
return Array.from(container.querySelectorAll('tbody tr')).map(
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -21,7 +21,9 @@ describe('admin shell scrolling contract', () => {
});
test('mobile navigation stays in one horizontally scrollable row', () => {
const mobileStyles = stylesheet.slice(stylesheet.indexOf('@media (max-width: 980px)'));
const mobileStyles = stylesheet.slice(
stylesheet.indexOf('@media (max-width: 980px)'),
);
expect(mobileStyles).toContain('.admin-bottom-nav {');
expect(mobileStyles).toContain('display: flex');
expect(mobileStyles).toContain('overflow-x: auto');
@@ -30,6 +32,37 @@ describe('admin shell scrolling contract', () => {
);
expect(mobileStyles).toContain('flex: 0 0 76px');
});
test('database preview headers stay on one line and expose ellipsis', () => {
const sortButton = ruleFor('.admin-table-sort-button');
const sortLabel = ruleFor('.admin-table-sort-button span');
expect(sortButton).toContain('white-space: nowrap');
expect(sortButton).toContain('overflow: hidden');
expect(sortLabel).toContain('text-overflow: ellipsis');
expect(sortLabel).toContain('white-space: nowrap');
});
test('copy feedback uses an auto-dismissing toast surface', () => {
const toast = ruleFor('.admin-toast');
expect(toast).toContain('position: fixed');
expect(toast).toContain('z-index: 120');
expect(toast).toContain('pointer-events: none');
expect(toast).toContain('animation: admin-toast-in');
expect(stylesheet).toContain(".admin-toast[data-tone='error']");
});
test('detail JSON keeps only a slim scrollbar thumb', () => {
const json = ruleFor('.admin-json-preview');
expect(json).toContain('scrollbar-width: thin');
expect(json).toContain('scrollbar-color: #d1b09a transparent');
expect(stylesheet).toContain(
'.admin-json-preview::-webkit-scrollbar-track',
);
expect(stylesheet).toContain('background: transparent');
expect(stylesheet).toContain(
'.admin-json-preview::-webkit-scrollbar-thumb',
);
});
});
function ruleFor(selector: string) {
@@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result
## Workflow
1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media.
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (PNG/JPEG/WEBP) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
@@ -8,7 +8,7 @@ The client projects three distinct facts:
Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance.
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered media path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true only for PNG/JPEG/WEBP files accepted by the current local-image registration contract; GIF/SVG and non-image files remain discoverable but must not be passed to the image importer. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local image. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`.
@@ -12,7 +12,7 @@ Treat the current working directory as the only project root.
1. Inspect the existing files needed for the request before editing.
2. The current working directory is the selected project root. Read and edit `index.html`, `style.css`, `game.js`, and `assets/` there unless the existing project deliberately uses a `game/` subdirectory for its source.
3. To discover media or other existing project files, call `agc_list_project_files` with an optional project-relative scope. It returns safe project-relative paths (including `assets/` and `game/`) plus bounded metadata; an unregistered file is only a discovery candidate, not a manifest asset.
4. Platform media and project-local media are exposed read-only through approved `agc_tools`; when a user asks to use an unregistered PNG/JPEG/WEBP, pass the returned project-relative path to `agc_import_account_assets.localPaths`, then re-read `agc_list_registered_assets` for the formal identity. Do not infer provenance or fabricate an asset ID from a filename.
4. Platform media and project-local media are exposed read-only through approved `agc_tools`; when a user asks to use an unregistered recognized image, font, audio, video, document, or code file, pass the returned project-relative path to `agc_import_account_assets.localPaths`, then re-read `agc_list_registered_assets` for the formal identity. Do not infer provenance or fabricate an asset ID from a filename.
5. Treat the parent `.agent/` directory as client-owned durable state. Do not read it with native file or shell tools; use the approved AGC tools when project identity or registered asset evidence is needed. Never hand-edit manifests, revisions, versions, ledgers, receipts, or provenance records.
6. Reuse existing files and asset identities. Do not create a second project root, hidden harness, Supervisor workspace, or parallel implementation.
7. Make the smallest coherent change that satisfies the user request, then inspect the actual changed files.
@@ -5,7 +5,7 @@
| `index.html` | Game source in the current cwd | Read and edit |
| `style.css` | Game source in the current cwd | Read and edit |
| `game.js` | Game source in the current cwd | Read and edit |
| `assets/` | Project media in the current cwd | Read and edit; import an unregistered PNG/JPEG/WEBP through `agc_import_account_assets.localPaths`; formal identity comes only after manifest registration |
| `assets/` | Project media in the current cwd | Read and edit; import an unregistered recognized resource through `agc_import_account_assets.localPaths`; formal identity comes only after manifest registration |
| Other project-root-relative files | Existing project files | Discover with `agc_list_project_files` or `file.list`; do not treat a path as a registered asset or expose sensitive/control paths |
| `.agent/` | AGC client state | Do not read or write with native tools |
@@ -21,7 +21,7 @@
"agents/openai.yaml",
"references/structure-contract.md"
],
"sha256": "2556b40c4e73c8af5c027d1222b569d34129880dfeabff38b50ad248307b5c0c"
"sha256": "85dd861201d7b9a5d34b702b7b79ce18012e06784dddf2fb8e836e085fee4b00"
},
{
"name": "taonier-art-assets",
@@ -106,7 +106,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "2e11baf232bd1a786cc3189a9183e3a687b846c4e0816393e5b5687551a5eeb7"
"sha256": "7800e8ee4b5baa6f473b904f262197f5320a46150ae5e51c56a298fc21674e30"
}
]
}
@@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
mod codex_app_server;
mod codex_cli;
mod codex_provider_proxy;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tools_mcp;
@@ -34,6 +36,8 @@ pub(crate) use codex_cli::{
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
};
pub(crate) use codex_provider_proxy::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
@@ -69,6 +73,7 @@ pub(crate) async fn request_game_creator_ui_editor_llm_at(
.with_api_kind(api_kind)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms);
let request = apply_game_creator_llm_reasoning_effort(request, &config.llm)?;
let snapshot = {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
@@ -1905,8 +1905,15 @@ impl CodexAppServerConnection {
request: LlmRunRequest,
on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None)
.await
self.run_turn_with_direct_observer(
snapshot,
llm,
request,
on_agent_message_delta,
None,
None,
)
.await
}
async fn run_turn_with_direct_observer(
@@ -1916,6 +1923,7 @@ impl CodexAppServerConnection {
request: LlmRunRequest,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let _turn_guard = self.inner.turn_gate.lock().await;
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
@@ -2085,6 +2093,11 @@ impl CodexAppServerConnection {
completed,
&params,
);
if completed {
if let Some(audit) = audit.as_mut() {
audit.observe_item(&params);
}
}
}
if item_type == "agentMessage" {
if let Some(text) = item
@@ -2796,8 +2809,14 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
system_prompt: String,
user_prompt: String,
) -> Result<String, String> {
direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None)
.await
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
user_prompt,
None,
None,
)
.await
}
pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
@@ -2811,6 +2830,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
system_prompt,
user_prompt,
Some(observer),
None,
)
.await
}
@@ -2861,11 +2881,12 @@ fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8])
format!("{:x}", digest.finalize())
}
async fn direct_game_creator_codex_chat_at_with_optional_observer(
pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<String, String> {
// Resolve project authority before deriving the pool/thread identity. A
// caller may hold a stable symlink path whose target changes between
@@ -2917,7 +2938,7 @@ async fn direct_game_creator_codex_chat_at_with_optional_observer(
.await
.map_err(|error| error.to_string())?;
connection
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer)
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit)
.await
.map(|value| value.text)
.map_err(|error| error.to_string())
@@ -4245,6 +4266,7 @@ while IFS= read -r line; do :; done
tool_request(),
Some(&mut on_delta),
Some(&mut observer),
None,
)
.await
.expect("run fake app-server turn");
@@ -0,0 +1,431 @@
//! Direct Codex 本轮附件 sidecarHome 与 Project 共用同一 DTO 和渲染函数。
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
const HOME_ATTACHMENT_HEADER: &str =
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
const PROJECT_ATTACHMENT_HEADER: &str =
"[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]";
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexTurnAttachment {
pub(crate) name: String,
pub(crate) media_type: String,
#[serde(default)]
pub(crate) size: u64,
#[serde(default)]
pub(crate) local_path: Option<String>,
#[serde(default)]
pub(crate) status: Option<String>,
}
pub(crate) fn sanitize_attachment_name(value: &str) -> String {
let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim();
let sanitized = basename
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS)
.collect::<String>();
if sanitized.is_empty() {
"未命名附件".to_string()
} else {
sanitized
}
}
pub(crate) fn sanitize_attachment_media_type(value: &str) -> String {
let value = value.trim();
if value.is_empty()
|| value.chars().any(|character| {
!(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_'))
})
{
"application/octet-stream".to_string()
} else {
value
.chars()
.take(MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS)
.collect()
}
}
pub(crate) fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> {
match value.map(str::trim) {
Some("imported") => Some("imported"),
Some("failed") => Some("failed"),
_ => None,
}
}
pub(crate) fn sanitize_attachment_local_path(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS
|| trimmed.chars().any(char::is_control)
{
return None;
}
let normalized = trimmed.replace('\\', "/");
if normalized.starts_with('/') {
return None;
}
let mut chars = normalized.chars();
if let (Some(letter), Some(':')) = (chars.next(), chars.next()) {
if letter.is_ascii_alphabetic() {
return None;
}
}
let mut segments = Vec::new();
for segment in normalized.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
return None;
}
segments.push(segment);
}
let first = segments.first()?;
if *first == ".agent" || *first == ".git" {
return None;
}
let path = segments.join("/");
if path.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS {
return None;
}
Some(path)
}
pub(crate) fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool {
attachments.iter().any(|attachment| {
attachment
.local_path
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| sanitize_attachment_status(attachment.status.as_deref()).is_some()
})
}
fn render_project_attachment_line(attachment: &DirectCodexTurnAttachment) -> String {
let name = sanitize_attachment_name(&attachment.name);
let media_type = sanitize_attachment_media_type(&attachment.media_type);
let raw_path = attachment
.local_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let sanitized_path = raw_path.and_then(sanitize_attachment_local_path);
let path_rejected = raw_path.is_some() && sanitized_path.is_none();
let status = if path_rejected {
Some("failed")
} else {
sanitize_attachment_status(attachment.status.as_deref())
};
let mut parts = vec![format!("原文件名:{name}")];
if let Some(path) = sanitized_path {
parts.push(format!("项目路径:{path}"));
}
parts.push(format!("类型:{media_type}"));
parts.push(format!("大小:{} 字节", attachment.size));
if let Some(status) = status {
parts.push(format!("状态:{status}"));
}
format!("- {}", parts.join(""))
}
pub(crate) fn render_direct_codex_user_prompt(
prompt: &str,
attachments: &[DirectCodexTurnAttachment],
) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() && attachments.is_empty() {
return Err("聊天内容不能为空".to_string());
}
if attachments.is_empty() {
return Ok(prompt.to_string());
}
let mut sections = Vec::new();
if !prompt.is_empty() {
sections.push(prompt.to_string());
sections.push(String::new());
}
if attachments_use_project_mapping(attachments) {
sections.push(PROJECT_ATTACHMENT_HEADER.to_string());
for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) {
sections.push(render_project_attachment_line(attachment));
}
} else {
sections.push(HOME_ATTACHMENT_HEADER.to_string());
for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) {
sections.push(format!(
"- {};类型:{};大小:{} 字节",
sanitize_attachment_name(&attachment.name),
sanitize_attachment_media_type(&attachment.media_type),
attachment.size,
));
}
}
if attachments.len() > MAX_DIRECT_CODEX_ATTACHMENTS {
sections.push(format!(
"- 另有 {} 个附件未展开",
attachments.len() - MAX_DIRECT_CODEX_ATTACHMENTS
));
}
Ok(sections.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
fn home_attachment(name: &str, media_type: &str, size: u64) -> DirectCodexTurnAttachment {
DirectCodexTurnAttachment {
name: name.to_string(),
media_type: media_type.to_string(),
size,
local_path: None,
status: None,
}
}
fn project_attachment(
name: &str,
media_type: &str,
size: u64,
local_path: Option<&str>,
status: Option<&str>,
) -> DirectCodexTurnAttachment {
DirectCodexTurnAttachment {
name: name.to_string(),
media_type: media_type.to_string(),
size,
local_path: local_path.map(str::to_string),
status: status.map(str::to_string),
}
}
#[test]
fn plain_prompt_is_trimmed_and_empty_prompt_without_attachments_is_rejected() {
assert_eq!(
render_direct_codex_user_prompt(" 你好 ", &[]).expect("plain prompt"),
"你好"
);
assert_eq!(
render_direct_codex_user_prompt("", &[]).expect_err("empty prompt"),
"聊天内容不能为空"
);
}
#[test]
fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() {
let attachments = vec![home_attachment(
r"C:\Users\secret\角色参考.png",
"image/png\nBearer secret",
3,
)];
let prompt = render_direct_codex_user_prompt(" 先看看这个附件 ", &attachments)
.expect("home prompt");
assert_eq!(
prompt,
"先看看这个附件\n\n[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]\n- 角色参考.png;类型:application/octet-stream;大小:3 字节"
);
assert!(!prompt.contains("C:\\Users"));
assert!(!prompt.contains("\nBearer secret"));
}
#[test]
fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() {
assert_eq!(
render_direct_codex_user_prompt("你好", &[]).expect("plain prompt"),
"你好"
);
let attachments = (0..MAX_DIRECT_CODEX_ATTACHMENTS + 2)
.map(|index| home_attachment(&format!("asset-{index}.png"), "image/png", index as u64))
.collect::<Vec<_>>();
let prompt =
render_direct_codex_user_prompt("看看素材", &attachments).expect("bounded attachments");
assert!(prompt.contains("asset-7.png"));
assert!(!prompt.contains("asset-8.png"));
assert!(prompt.contains("另有 2 个附件未展开"));
assert!(render_direct_codex_user_prompt("", &attachments).is_ok());
assert!(render_direct_codex_user_prompt("", &[]).is_err());
}
#[test]
fn home_json_without_path_or_status_still_deserializes() {
let attachment: DirectCodexTurnAttachment =
serde_json::from_str(r#"{"name":"a.png","mediaType":"image/png","size":3}"#)
.expect("home json");
assert!(attachment.local_path.is_none());
assert!(attachment.status.is_none());
assert_eq!(attachment.size, 3);
}
#[test]
fn project_prompt_keeps_user_text_and_maps_original_name_to_project_path() {
let attachments = vec![project_attachment(
"fast_gdd.md",
"text/markdown",
7944,
Some("assets/uploads/upload-1788083777445-fast_gdd.md"),
Some("imported"),
)];
let prompt = render_direct_codex_user_prompt("请根据附件做游戏", &attachments)
.expect("project prompt");
assert_eq!(
prompt,
"请根据附件做游戏\n\n[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]\n- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported"
);
assert!(!prompt.contains("GDD"));
assert!(!prompt.contains("规格"));
assert!(!prompt.contains("权威"));
assert!(!prompt.contains("必须读取"));
}
#[test]
fn project_png_and_markdown_share_the_same_line_shape() {
let attachments = vec![
project_attachment(
"角色参考.png",
"image/png",
12,
Some("assets/uploads/upload-1-角色参考.png"),
Some("imported"),
),
project_attachment(
"notes.md",
"text/markdown",
80,
Some("assets/uploads/upload-2-notes.md"),
Some("imported"),
),
];
let prompt =
render_direct_codex_user_prompt("看这两个附件", &attachments).expect("mixed types");
let lines: Vec<_> = prompt
.lines()
.filter(|line| line.starts_with("- 原文件名:"))
.collect();
assert_eq!(lines.len(), 2);
for line in &lines {
assert!(line.contains(";项目路径:assets/uploads/"));
assert!(line.contains(";类型:"));
assert!(line.contains(";大小:"));
assert!(line.contains(";状态:imported"));
}
assert!(lines[0].contains("角色参考.png"));
assert!(lines[0].contains("image/png"));
assert!(lines[1].contains("notes.md"));
assert!(lines[1].contains("text/markdown"));
}
#[test]
fn failed_attachment_without_path_has_status_and_no_error_body() {
let attachments = vec![project_attachment(
"lost.bin",
"application/octet-stream",
2,
None,
Some("failed"),
)];
let prompt =
render_direct_codex_user_prompt("附件失败了", &attachments).expect("failed prompt");
assert!(prompt.contains(PROJECT_ATTACHMENT_HEADER));
assert!(prompt.contains("原文件名:lost.bin"));
assert!(prompt.contains("状态:failed"));
assert!(!prompt.contains("项目路径:"));
assert!(!prompt.contains("error"));
assert!(!prompt.contains("失败原因"));
}
#[test]
fn illegal_local_paths_are_omitted_and_marked_failed() {
let attachments = vec![
project_attachment(
"up.md",
"text/markdown",
1,
Some("../secret.md"),
Some("imported"),
),
project_attachment(
"agent.md",
"text/markdown",
1,
Some(".agent/conversations/x.md"),
Some("imported"),
),
project_attachment(
"abs.md",
"text/markdown",
1,
Some(r"C:\tmp\abs.md"),
Some("imported"),
),
project_attachment(
"unix.md",
"text/markdown",
1,
Some("/tmp/unix.md"),
Some("imported"),
),
];
let prompt =
render_direct_codex_user_prompt("非法路径", &attachments).expect("illegal paths");
assert!(!prompt.contains("../secret.md"));
assert!(!prompt.contains(".agent/conversations/x.md"));
assert!(!prompt.contains("C:\\tmp\\abs.md"));
assert!(!prompt.contains("/tmp/unix.md"));
assert!(!prompt.contains("项目路径:"));
assert_eq!(prompt.matches("状态:failed").count(), 4);
assert!(!prompt.contains("状态:imported"));
}
#[test]
fn empty_prompt_with_project_attachments_still_renders() {
let attachments = vec![project_attachment(
"ref.png",
"image/png",
4,
Some("assets/uploads/upload-1-ref.png"),
Some("imported"),
)];
let prompt = render_direct_codex_user_prompt(" ", &attachments).expect("empty user text");
assert!(prompt.starts_with(PROJECT_ATTACHMENT_HEADER));
assert!(prompt.contains("项目路径:assets/uploads/upload-1-ref.png"));
}
#[test]
fn unknown_error_field_is_not_forwarded_to_the_model() {
let attachment: DirectCodexTurnAttachment = serde_json::from_str(
r#"{"name":"a.md","mediaType":"text/markdown","size":1,"status":"failed","error":"secret boom"}"#,
)
.expect("extra error field");
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
assert!(!prompt.contains("secret boom"));
assert!(!prompt.contains("error"));
}
#[test]
fn unknown_status_keeps_home_attachment_metadata_shape() {
let attachment =
project_attachment("pending.md", "text/markdown", 1, None, Some("pending"));
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
assert!(prompt.contains(HOME_ATTACHMENT_HEADER));
assert!(!prompt.contains(PROJECT_ATTACHMENT_HEADER));
assert!(!prompt.contains("状态:"));
}
}
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,6 @@ use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8;
const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160;
const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
@@ -3714,14 +3711,6 @@ pub(crate) fn build_direct_codex_home_system_prompt() -> String {
.join("\n")
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexHomeAttachment {
name: String,
media_type: String,
size: u64,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexHomeReply {
@@ -3754,78 +3743,11 @@ fn parse_direct_codex_home_reply(reply: String) -> DirectCodexHomeReply {
}
}
fn direct_codex_home_attachment_name(value: &str) -> String {
let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim();
let sanitized = basename
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS)
.collect::<String>();
if sanitized.is_empty() {
"未命名附件".to_string()
} else {
sanitized
}
}
fn direct_codex_home_attachment_media_type(value: &str) -> String {
let value = value.trim();
if value.is_empty()
|| value.chars().any(|character| {
!(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_'))
})
{
"application/octet-stream".to_string()
} else {
value
.chars()
.take(MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS)
.collect()
}
}
fn render_direct_codex_home_user_prompt(
prompt: &str,
attachments: &[DirectCodexHomeAttachment],
) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() && attachments.is_empty() {
return Err("聊天内容不能为空".to_string());
}
if attachments.is_empty() {
return Ok(prompt.to_string());
}
let mut sections = Vec::new();
if !prompt.is_empty() {
sections.push(prompt.to_string());
sections.push(String::new());
}
sections.push(
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]".to_string(),
);
for attachment in attachments.iter().take(MAX_DIRECT_HOME_ATTACHMENTS) {
sections.push(format!(
"- {};类型:{};大小:{} 字节",
direct_codex_home_attachment_name(&attachment.name),
direct_codex_home_attachment_media_type(&attachment.media_type),
attachment.size,
));
}
if attachments.len() > MAX_DIRECT_HOME_ATTACHMENTS {
sections.push(format!(
"- 另有 {} 个附件未展开",
attachments.len() - MAX_DIRECT_HOME_ATTACHMENTS
));
}
Ok(sections.join("\n"))
}
pub(crate) async fn run_direct_game_creator_home_turn(
prompt: &str,
attachments: &[DirectCodexHomeAttachment],
attachments: &[DirectCodexTurnAttachment],
) -> Result<DirectCodexHomeReply, String> {
let user_prompt = render_direct_codex_home_user_prompt(prompt, attachments)?;
let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?;
direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt)
.await
.map(parse_direct_codex_home_reply)
@@ -3855,6 +3777,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
prompt,
creation_type,
None,
None,
)
.await
}
@@ -3864,6 +3787,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
prompt: &str,
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<String, String> {
if !root.is_absolute() || !root.is_dir() {
return Err("当前项目目录不存在或不是绝对路径".to_string());
@@ -3879,7 +3803,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
if let Some(emitter) = turn_emitter {
emitter.emit("accepted", Some("request-accepted"), None);
}
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await {
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await
{
Ok(reply) => Ok(reply),
Err(failure) => {
let error = record_direct_codex_turn_failure(root, failure);
@@ -3896,6 +3821,7 @@ async fn run_direct_game_creator_turn_inner(
prompt: &str,
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<String, DirectCodexTurnFailure> {
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter {
@@ -3924,15 +3850,23 @@ async fn run_direct_game_creator_turn_inner(
);
}
};
direct_game_creator_codex_chat_at_with_observer(
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
prompt.to_string(),
&mut observer,
Some(&mut observer),
audit,
)
.await
} else {
direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
prompt.to_string(),
None,
audit,
)
.await
}
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
if let Some(emitter) = turn_emitter {
@@ -4035,7 +3969,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
if prepare_art {
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实美术资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的 PNG/JPEG/WEBP,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档或代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码");
} else {
system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。");
@@ -4259,6 +4193,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
prompt: String,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -4267,21 +4202,47 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let reply = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
let mut audit = DirectCodexTurnAudit::start(
root,
&turn_id,
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let user_prompt = match render_direct_codex_user_prompt(
&prompt,
attachments.as_deref().unwrap_or_default(),
) {
Ok(prompt) => prompt,
Err(error) => {
audit.finish(false);
return Err(error);
}
};
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
creation_type.as_deref(),
Some(&turn_emitter),
Some(&mut audit),
)
.await?;
persist_direct_codex_assistant_reply_at(root, &turn_id, &reply).map_err(|error| {
.await
{
Ok(reply) => reply,
Err(error) => {
audit.finish(false);
return Err(error);
}
};
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
redact_agent_runtime_error(
return Err(redact_agent_runtime_error(
root,
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
500,
)
})?;
));
}
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
Ok(reply)
}
@@ -4289,7 +4250,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
#[tauri::command]
pub(crate) async fn chat_with_game_creator_home_direct_codex(
prompt: String,
attachments: Option<Vec<DirectCodexHomeAttachment>>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<DirectCodexHomeReply, String> {
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
}
@@ -4491,47 +4452,6 @@ mod tests {
assert!(!prompt.contains("game/index.html"));
}
#[test]
fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() {
let attachments = vec![DirectCodexHomeAttachment {
name: r"C:\Users\secret\角色参考.png".to_string(),
media_type: "image/png\nBearer secret".to_string(),
size: 3,
}];
let prompt = render_direct_codex_home_user_prompt(" 先看看这个附件 ", &attachments)
.expect("home prompt");
assert!(prompt.starts_with("先看看这个附件\n\n[首页附件说明"));
assert!(prompt.contains("角色参考.png"));
assert!(prompt.contains("类型:application/octet-stream"));
assert!(prompt.contains("大小:3 字节"));
assert!(!prompt.contains("C:\\Users"));
assert!(!prompt.contains("\nBearer secret"));
}
#[test]
fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() {
assert_eq!(
render_direct_codex_home_user_prompt("你好", &[]).expect("plain prompt"),
"你好"
);
let attachments = (0..MAX_DIRECT_HOME_ATTACHMENTS + 2)
.map(|index| DirectCodexHomeAttachment {
name: format!("asset-{index}.png"),
media_type: "image/png".to_string(),
size: index as u64,
})
.collect::<Vec<_>>();
let prompt = render_direct_codex_home_user_prompt("看看素材", &attachments)
.expect("bounded attachments");
assert!(prompt.contains("asset-7.png"));
assert!(!prompt.contains("asset-8.png"));
assert!(prompt.contains("另有 2 个附件未展开"));
assert!(render_direct_codex_home_user_prompt("", &attachments).is_ok());
assert!(render_direct_codex_home_user_prompt("", &[]).is_err());
}
#[test]
fn home_create_marker_is_accepted_only_as_the_first_reply_token() {
let requested = parse_direct_codex_home_reply(format!(
@@ -1268,6 +1268,8 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
"png" => ("image", Some("image/png")),
"jpg" | "jpeg" => ("image", Some("image/jpeg")),
"webp" => ("image", Some("image/webp")),
"avif" => ("image", Some("image/avif")),
"bmp" => ("image", Some("image/bmp")),
"gif" => ("image", Some("image/gif")),
"svg" => ("image", Some("image/svg+xml")),
"ttf" => ("font", Some("font/ttf")),
@@ -1279,15 +1281,26 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
"ogg" => ("audio", Some("audio/ogg")),
"flac" => ("audio", Some("audio/flac")),
"m4a" => ("audio", Some("audio/mp4")),
"aac" => ("audio", Some("audio/aac")),
"opus" => ("audio", Some("audio/opus")),
"mp4" => ("video", Some("video/mp4")),
"webm" => ("video", Some("video/webm")),
"mov" => ("video", Some("video/quicktime")),
"md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml"
| "csv" | "ini" | "conf" | "xml" => ("document", Some(match extension.as_str() {
"json" => "application/json",
"yaml" | "yml" => "application/yaml",
"xml" => "application/xml",
_ => "text/plain",
})),
"html" | "htm" => ("code", Some("text/html")),
"css" => ("code", Some("text/css")),
"css" | "scss" | "less" => ("code", Some("text/css")),
"js" | "mjs" | "cjs" | "ts" | "tsx" => ("code", Some("text/javascript")),
"json" => ("code", Some("application/json")),
"md" | "txt" => ("code", Some("text/plain")),
"gd" => ("code", Some("text/plain")),
"gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp"
| "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash"
| "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => {
("code", Some("text/plain"))
}
_ => ("other", None),
}
}
@@ -1295,7 +1308,7 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
fn bridge_project_file_is_asset_importable(path: &str) -> bool {
matches!(
bridge_project_file_class(path).1,
Some("image/png" | "image/jpeg" | "image/webp")
Some(_)
)
}
@@ -1332,7 +1345,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
.map(|value| value.to_lowercase());
let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)?
.unwrap_or_else(|| "all".to_string());
if !["all", "image", "font", "audio", "video", "code"].contains(&requested_kind.as_str()) {
if !["all", "image", "font", "audio", "video", "document", "code"].contains(&requested_kind.as_str()) {
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
}
let (offset, limit) = bridge_account_asset_page(arguments)?;
@@ -1396,7 +1409,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
"limit": limit,
"nextOffset": next_offset,
"files": page,
"next": "仅把未登记且 assetImportable=true 的 PNG/JPEG/WEBP path 作为项目相对 localPaths 交给 agc_import_account_assets;登记后再用 agc_list_registered_assets 获取 localAssetId。"
"next": "仅把未登记且 assetImportable=true 的项目相对路径交给 agc_import_account_assets;登记后再用 agc_list_registered_assets 获取 localAssetId。"
}))
})();
match result {
@@ -1542,7 +1555,7 @@ async fn bridge_list_account_assets(state: &DirectToolBridgeState, arguments: &V
"limit": limit,
"nextOffset": next_offset,
"assets": page,
"next": "账户素材或 project-canvas 资源使用返回的 assetId/resourceId 调用 agc_import_account_assets;项目内本地图片先用 agc_list_project_files;不要提交 objectKey、URL 或宿主绝对路径"
"next": "账户素材或 project-canvas 资源使用返回的 assetId/resourceId 调用 agc_import_account_assets;项目内本地资源先用 agc_list_project_files;不要提交 objectKey、URL 或宿主绝对路径"
}))
}
.await;
@@ -1590,7 +1603,7 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments:
}
}
if !local_paths.is_empty() {
match import_local_project_image_assets_for_agent(&state.root, &local_paths) {
match import_local_project_assets_for_agent(&state.root, &local_paths) {
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
json!({
"id": asset.id,
@@ -2407,15 +2420,22 @@ mod tests {
"supported raster image should be importable: {path}"
);
}
for path in ["assets/theme.bin", "assets/unknown.xyz"] {
assert!(
!bridge_project_file_is_asset_importable(path),
"unsupported project file must not be advertised as importable: {path}"
);
}
for path in [
"assets/hero.gif",
"assets/hero.svg",
"assets/theme.mp3",
"game/index.html",
"assets/design.md",
] {
assert!(
!bridge_project_file_is_asset_importable(path),
"unsupported project file must not be advertised as importable: {path}"
bridge_project_file_is_asset_importable(path),
"recognized project file should be advertised as importable: {path}"
);
}
}
@@ -2463,8 +2483,8 @@ mod tests {
})
.collect::<BTreeMap<_, _>>();
assert_eq!(importability.get("assets/hero.png"), Some(&true));
assert_eq!(importability.get("assets/preview.gif"), Some(&false));
assert_eq!(importability.get("assets/vector.svg"), Some(&false));
assert_eq!(importability.get("assets/preview.gif"), Some(&true));
assert_eq!(importability.get("assets/vector.svg"), Some(&true));
}
#[test]
@@ -212,7 +212,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
json!({
"name": "agc_list_project_files",
"description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地图片。结果只返回项目相对路径、大小、文件类别、是否已登记及 assetImportable;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。仅把 assetImportable=true 的 PNG/JPEG/WEBP 项目相对路径交给 agc_import_account_assets.localPaths。",
"description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地资源。结果只返回项目相对路径、大小、文件类别、是否已登记及 assetImportable;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。仅把 assetImportable=true 的项目相对路径交给 agc_import_account_assets.localPaths。",
"inputSchema": {
"type": "object",
"properties": {
@@ -229,7 +229,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
},
"kind": {
"type": "string",
"enum": ["all", "image", "font", "audio", "video", "code"]
"enum": ["all", "image", "font", "audio", "video", "document", "code"]
},
"offset": { "type": "integer", "minimum": 0, "maximum": 500 },
"limit": { "type": "integer", "minimum": 1, "maximum": 100 }
@@ -239,7 +239,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
json!({
"name": "agc_list_account_assets",
"description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片安全投影。客户端重新校验当前账号并隐藏 objectKey、URL、签名地址、宿主路径和凭据;结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地图片另用 agc_list_project_files 发现。",
"description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片安全投影。客户端重新校验当前账号并隐藏 objectKey、URL、签名地址、宿主路径和凭据;结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源另用 agc_list_project_files 发现。",
"inputSchema": {
"type": "object",
"properties": {
@@ -253,7 +253,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
json!({
"name": "agc_import_account_assets",
"description": "导入账户图片、已绑定网页项目画布图片或项目内本地图片。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地图片只能使用 agc_list_project_files 返回的项目根相对 localPaths(包括 assets/ 与 game/),不得使用 .agent、父级穿越或宿主绝对路径。assetIds 与 localPaths 可混合提交;客户端负责账号/项目归属、换签下载、格式/大小校验、项目锁、manifest 与 revision,模型不得提交 objectKey、URL 或凭据。",
"description": "导入账户图片、已绑定网页项目画布图片或项目内本地资源。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地资源只能使用 agc_list_project_files 返回的项目根相对 localPaths(包括 assets/ 与 game/),不得使用 .agent、父级穿越或宿主绝对路径。assetIds 与 localPaths 可混合提交;客户端负责账号/项目归属、换签下载、格式/大小校验、项目锁、manifest 与 revision,模型不得提交 objectKey、URL 或凭据。",
"inputSchema": {
"type": "object",
"properties": {
@@ -569,7 +569,9 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String>
}
if arguments.get("kind").is_some() {
let kind = bounded_tool_string(arguments, "kind", 16)?;
if !["all", "image", "font", "audio", "video", "code"].contains(&kind.as_str()) {
if !["all", "image", "font", "audio", "video", "document", "code"]
.contains(&kind.as_str())
{
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
}
}
@@ -1343,6 +1345,11 @@ mod tests {
"limit": 100
}))
.is_ok());
assert!(validate_project_file_list_arguments(&json!({
"path": "assets",
"kind": "document"
}))
.is_ok());
assert!(validate_project_file_list_arguments(&json!({
"path": "../outside"
}))
@@ -159,10 +159,10 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
output.push_str(
"# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\
- 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\
- 只有 `assetImportable=true` 的 PNG/JPEG/WEBP 可交给当前图片导入工具;其它媒体只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档或代码文件可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
);
for (path, size, media_type) in unregistered.iter().take(48) {
let asset_importable = matches!(*media_type, "image/png" | "image/jpeg" | "image/webp");
let asset_importable = !media_type.is_empty();
output.push_str(&format!(
"- {path} / {media_type} / {size} bytes / registered=false / assetImportable={asset_importable}\n"
));
@@ -200,6 +200,8 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> {
"jpg" | "jpeg" => Some("image/jpeg"),
"webp" => Some("image/webp"),
"gif" => Some("image/gif"),
"avif" => Some("image/avif"),
"bmp" => Some("image/bmp"),
"svg" => Some("image/svg+xml"),
"ttf" => Some("font/ttf"),
"otf" => Some("font/otf"),
@@ -210,13 +212,48 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> {
"ogg" => Some("audio/ogg"),
"flac" => Some("audio/flac"),
"m4a" => Some("audio/mp4"),
"aac" => Some("audio/aac"),
"opus" => Some("audio/opus"),
"mp4" => Some("video/mp4"),
"webm" => Some("video/webm"),
"mov" => Some("video/quicktime"),
"json" => Some("application/json"),
"yaml" | "yml" => Some("application/yaml"),
"xml" => Some("application/xml"),
"html" | "htm" => Some("text/html"),
"md" | "markdown" | "mdx" | "txt" | "toml" | "csv" | "ini" | "conf" => {
Some("text/plain")
}
"css" | "scss" | "less" => Some("text/css"),
"js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go"
| "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs"
| "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql"
| "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::prompt_context_media_type;
#[test]
fn prompt_context_media_type_covers_all_importable_resource_categories() {
for (path, expected) in [
("assets/icon.avif", "image/avif"),
("assets/font.woff2", "font/woff2"),
("assets/theme.opus", "audio/opus"),
("assets/intro.mov", "video/quicktime"),
("assets/data.json", "application/json"),
("game/index.html", "text/html"),
("game/main.rs", "text/plain"),
] {
assert_eq!(prompt_context_media_type(path), Some(expected), "{path}");
}
assert_eq!(prompt_context_media_type("assets/unknown.bin"), None);
}
}
pub(crate) fn render_local_conversation_prompt_context(
root: &Path,
agent_id: Option<&str>,
@@ -384,8 +384,8 @@ fn build_game_creator_agent_background_tool_plan_request_at(
"file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}}file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}}file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}}file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n",
"task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 IDtask.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}}{limited_command_contract}\n",
"canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9imageSize 只允许 0.5K|1K|2KassetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n",
"asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地图片\n",
"canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}}assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对 PNG/JPEG/WEBP 路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后 Runtime 会下载/校验或登记本地图片、更新 manifest 并推进 revision。\n",
"asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源\n",
"canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}}assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对已识别资源路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后 Runtime 会下载/校验或登记本地资源、更新 manifest 并推进 revision。\n",
"blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}}agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}}agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}}expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 nullagent.schedule_ready 使用 {{\"limit\":1}}agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n",
"只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}}asset.library.list 也允许使用其广告 schema 中的全 null/分页默认值;其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。"
),
@@ -715,36 +715,6 @@ fn build_game_creator_agent_background_tool_plan_request_at(
))
}
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
root: &Path,
agent_id: &str,
session_id: &str,
run_id: &str,
task: &str,
observations: &[AgentRuntimeToolObservation],
loop_index: usize,
) -> Result<
(
GameCreatorLlmConfig,
String,
LlmRunRequest,
String,
AgentRuntimeToolPlanRequestSnapshot,
),
String,
> {
build_game_creator_agent_background_tool_plan_request_at(
root,
None,
agent_id,
session_id,
run_id,
task,
observations,
loop_index,
)
}
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked(
root: &Path,
project_lock: &ProjectWriteLock,
@@ -972,17 +942,20 @@ mod tests {
use crate::{update_manifest_task_status_at, GameCreationAppTaskStatus};
use super::{
acquire_game_creator_agent_provider_plan_project_write_lock_with_wait,
agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at,
build_game_creator_agent_background_final_reply_request,
build_game_creator_agent_background_tool_plan_request,
build_game_creator_agent_background_tool_plan_request_locked,
game_creator_agent_context_preload_notice,
game_creator_agent_runtime_run_profile_binding_path,
game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at,
new_game_creation_app_seed_tasks, provider_command_exec_contract,
provider_command_start_contract, render_relaxed_autonomous_manifest_ready_task_background_prompt,
provider_command_start_contract,
render_relaxed_autonomous_manifest_ready_task_background_prompt,
required_runtime_prompt_section, start_game_creator_agent_runtime_task_at,
AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft,
AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan,
AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig,
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT,
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND,
@@ -996,6 +969,40 @@ mod tests {
RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
};
fn build_game_creator_agent_background_tool_plan_request_for_test(
root: &std::path::Path,
agent_id: &str,
session_id: &str,
run_id: &str,
task: &str,
observations: &[AgentRuntimeToolObservation],
loop_index: usize,
) -> Result<
(
GameCreatorLlmConfig,
String,
platform_llm::LlmRunRequest,
String,
AgentRuntimeToolPlanRequestSnapshot,
),
String,
> {
let lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"test.provider_request.build.tool_plan",
)?;
build_game_creator_agent_background_tool_plan_request_locked(
root,
&lock,
agent_id,
session_id,
run_id,
task,
observations,
loop_index,
)
}
fn native_input_required_fields(
request: &platform_llm::LlmRunRequest,
tool: &str,
@@ -1066,7 +1073,7 @@ mod tests {
summary: "结构化计划更新被 Runtime 拒绝".to_string(),
detail: Some("计划状态回退".to_string()),
};
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
&root,
&state.agent_id,
&state.session_id,
@@ -1155,16 +1162,17 @@ mod tests {
// Relaxed orchestration does not convert an idle planning counter into
// a tool-removal gate; the Provider remains free to choose its next
// action.
let (_, _, baseline, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
&state.agent_id,
&state.session_id,
&state.run_id,
&state.current_task,
&[],
1,
)
.expect("build baseline request");
let (_, _, baseline, _, _) =
build_game_creator_agent_background_tool_plan_request_for_test(
&root,
&state.agent_id,
&state.session_id,
&state.run_id,
&state.current_task,
&[],
1,
)
.expect("build baseline request");
assert!(baseline
.function_tools
.iter()
@@ -1175,7 +1183,7 @@ mod tests {
crate::agent::write_game_creator_agent_runtime_state(&root, &idle_state)
.expect("persist idle rounds");
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
&root,
&state.agent_id,
&state.session_id,
@@ -1256,7 +1264,7 @@ mod tests {
vec!["交付当前 manifest task".to_string()],
)
.expect("start autonomous ready child task");
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
&root,
agent_id,
&state.session_id,
@@ -1506,7 +1514,7 @@ mod tests {
vec!["冻结 Goal Contract".to_string()],
)
.expect("start trusted root");
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
&root,
&state.agent_id,
&state.session_id,
@@ -1566,7 +1574,7 @@ mod tests {
)
.expect("start plan root");
let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request(
let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
&root,
&state.agent_id,
&state.session_id,
@@ -1633,7 +1641,7 @@ mod tests {
},
)
.expect("freeze plan contract");
let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request(
let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
&root,
&state.agent_id,
&state.session_id,
@@ -1719,7 +1727,7 @@ mod tests {
)
.expect("start supervisor runtime state");
let (_, _, supervisor_request, _, _) =
build_game_creator_agent_background_tool_plan_request(
build_game_creator_agent_background_tool_plan_request_for_test(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&supervisor_state.session_id,
@@ -1865,16 +1873,17 @@ mod tests {
vec!["核对普通说明".to_string()],
)
.expect("start ordinary runtime state");
let (_, _, ordinary_request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
"code-prototype",
&ordinary_state.session_id,
&ordinary_state.run_id,
&ordinary_state.current_task,
&[],
0,
)
.expect("build ordinary planning request");
let (_, _, ordinary_request, _, _) =
build_game_creator_agent_background_tool_plan_request_for_test(
&root,
"code-prototype",
&ordinary_state.session_id,
&ordinary_state.run_id,
&ordinary_state.current_task,
&[],
0,
)
.expect("build ordinary planning request");
assert!(ordinary_request.messages[0]
.content
.contains("你正在使用 Genarrative AI 游戏创作多智能体 Runtime"));
@@ -1989,16 +1998,17 @@ mod tests {
vec!["读取需求并准备澄清".to_string()],
)
.expect("start planning child");
let (_, _, planning_request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
&planning_state.session_id,
&planning_state.run_id,
&planning_state.current_task,
&[],
0,
)
.expect("build planning request");
let (_, _, planning_request, _, _) =
build_game_creator_agent_background_tool_plan_request_for_test(
&root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
&planning_state.session_id,
&planning_state.run_id,
&planning_state.current_task,
&[],
0,
)
.expect("build planning request");
let planning_system_prompt = &planning_request.messages[0].content;
let planning_brief_marker = "你是“立项策划 Agent”(`agentId=project-planning`";
assert!(planning_system_prompt.contains(planning_brief_marker));
@@ -2074,7 +2084,7 @@ mod tests {
)
.expect("start supervisor");
let (_, _, supervisor_request, _, _) =
build_game_creator_agent_background_tool_plan_request(
build_game_creator_agent_background_tool_plan_request_for_test(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&supervisor_state.session_id,
@@ -2183,7 +2193,7 @@ mod tests {
},
];
let (_, _, request, _, request_snapshot) =
build_game_creator_agent_background_tool_plan_request(
build_game_creator_agent_background_tool_plan_request_for_test(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&state.session_id,
@@ -2209,7 +2219,7 @@ mod tests {
),
});
let (_, _, _, _, settled_request_snapshot) =
build_game_creator_agent_background_tool_plan_request(
build_game_creator_agent_background_tool_plan_request_for_test(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&state.session_id,
@@ -216,23 +216,6 @@ pub(crate) fn pending_matches_receipt(
/// Construct the independent planning pending projection after an external
/// acceptance gate has succeeded. M1C-1 does not decide whether the gate
/// passed; the caller must supply that fact and the exact GDD identity.
pub(crate) fn create_plan_gdd_approval_pending_at(
root: &Path,
gdd: &PlanGddV1,
) -> Result<(), PlanningStorageError> {
if !crate::config::game_creator_planning_capability_enabled()
.map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))?
{
return Err(approval_error(
"PLAN_CAPABILITY_DISABLED",
"立项策划能力当前已停用",
));
}
let _lock = acquire_project_write_lock(root, "planning.approval-pending.create")
.map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?;
create_plan_gdd_approval_pending_locked(root, gdd)
}
pub(crate) fn create_plan_gdd_approval_pending_locked(
root: &Path,
gdd: &PlanGddV1,
@@ -2357,13 +2340,6 @@ pub(crate) fn plan_gdd_typed_completion_blocker_at_locked(
),
));
}
if session.phase == "recovery_required" {
return Some(plan_gdd_completion_blocker(
"needs-reconciliation",
"planning session 仍处于 recovery_required,不能收束任务",
format!("gddVersion={}", latest.version),
));
}
None
}
@@ -2408,8 +2408,7 @@ pub(crate) fn validate_plan_session_successor(
&& next.active_run_id.is_some()
&& next.last_run_id == next.active_run_id.clone().unwrap_or_default();
if next.run_profile_binding_fingerprint != previous.run_profile_binding_fingerprint
&& (!active_run_changed
|| !matches!(next.phase.as_str(), "collecting" | "revision_requested"))
&& !active_run_changed
{
return Err(conflict(
"session 只有在绑定新的 active planning child 时才能更换 Run Profile binding fingerprint",
@@ -2712,18 +2711,6 @@ pub(crate) fn canonical_plan_submit_gdd_input_bytes(
Ok(bytes)
}
pub(crate) fn parse_plan_submit_gdd_input_bytes(
bytes: &[u8],
) -> Result<PlanSubmitGddInputV1, PlanningStorageError> {
let value = parse_strict_canonical::<PlanSubmitGddInputV1>(
bytes,
"plan.submit_gdd input",
PLAN_GDD_MAX_BYTES,
)?;
validate_plan_submit_gdd_input(&value)?;
Ok(value)
}
pub(crate) fn validate_plan_gdd_chain(values: &[PlanGddV1]) -> Result<(), PlanningStorageError> {
if values.len() > PLAN_MAX_VERSIONS as usize {
return Err(PlanningStorageError::new(
@@ -5067,22 +5054,23 @@ mod tests {
fn submit_input_has_strict_canonical_parser_and_runtime_field_boundary() {
let value = golden_submit_input();
let bytes = canonical_plan_submit_gdd_input_bytes(&value).expect("submit input bytes");
assert_eq!(
parse_plan_submit_gdd_input_bytes(&bytes).expect("parse input"),
value
);
let parse = |bytes: &[u8]| -> Result<PlanSubmitGddInputV1, PlanningStorageError> {
let value = parse_strict_canonical::<PlanSubmitGddInputV1>(
bytes,
"plan.submit_gdd input",
PLAN_GDD_MAX_BYTES,
)?;
validate_plan_submit_gdd_input(&value)?;
Ok(value)
};
assert_eq!(parse(&bytes).expect("parse input"), value);
let mut newline = bytes.clone();
newline.push(b'\n');
assert_eq!(
parse_plan_submit_gdd_input_bytes(&newline)
.unwrap_err()
.code(),
"PLAN_NON_CANONICAL_BYTES"
);
assert_eq!(parse(&newline).unwrap_err().code(), "PLAN_NON_CANONICAL_BYTES");
let mut object = serde_json::from_slice::<serde_json::Value>(&bytes).expect("input json");
object["projectId"] = serde_json::Value::String("forged-project".to_string());
let forged = serde_json::to_vec(&object).expect("forged input");
assert!(parse_plan_submit_gdd_input_bytes(&forged).is_err());
assert!(parse(&forged).is_err());
}
#[test]
@@ -1166,15 +1166,7 @@ fn validate_current_session_cas(
"plan.submit_gdd 必须绑定当前活跃策划子 Run",
));
}
// 这条 phase 判据实际只可能看到 `collecting`:上面的 activeRunId 判据要求
// session 绑着当前策划子 run,而 schema 不变量禁止 `awaiting_user_input`、
// `awaiting_gdd_approval`、`revision_requested`、`approved`、`rejected`、
// `recovery_required` 保留 activeRunIdplanning_storage.rs 的
// 「session 进入审批/终态/recovery_required 后不得保留 activeRunId」)。
// 因此 revise/reject 之后能不能重做,不由这条门决定,而由 M1C-2b 的 continuation
// 起点 writer 决定——它必须以新 activeRunId 写 revision+1 successorphase 只能落回
// `collecting`。这里保留 `revision_requested` 作为既有冗余,不再新增更多不可达分支。
if !matches!(session.phase.as_str(), "collecting" | "revision_requested") {
if session.phase != "collecting" {
return Err(submit_error(
"PLAN_PENDING_GDD_EXISTS",
"当前 planning session 仍有未决 GDD",
@@ -1641,11 +1633,7 @@ fn project_submit_successors_locked(
let source_session_matches_gdd = session_identity_matches_gdd
&& previous_session.session_revision == gdd.source_session_revision
&& previous_session.session_fingerprint == gdd.source_session_fingerprint
&& previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str())
&& matches!(
previous_session.phase.as_str(),
"collecting" | "revision_requested"
);
&& previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str());
if !session_identity_matches_gdd {
recovery_pending = true;
} else if same_ref {
@@ -1896,6 +1884,15 @@ mod tests {
use std::fs;
use std::path::PathBuf;
fn create_plan_gdd_approval_pending_for_test(
root: &std::path::Path,
gdd: &PlanGddV1,
) -> Result<(), PlanningStorageError> {
let _lock = acquire_project_write_lock(root, "test.planning.approval-pending.create")
.map_err(|error| PlanningStorageError::new("PLAN_DURABILITY_FAILED", error))?;
create_plan_gdd_approval_pending_locked(root, gdd)
}
fn valid_input() -> PlanSubmitGddInputV1 {
PlanSubmitGddInputV1 {
schema_version: PLAN_SUBMIT_INPUT_SCHEMA.to_string(),
@@ -3628,7 +3625,7 @@ mod tests {
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
let decision_input = approval_input(
&gdd,
"approve",
@@ -3654,13 +3651,13 @@ mod tests {
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
let pending = read_plan_gdd_approval_pending_locked(&root)
.expect("read approval pending")
.expect("pending exists");
assert_eq!(pending.status, "awaiting_decision");
// Recreating the exact card is an idempotent replay.
create_plan_gdd_approval_pending_at(&root, &gdd).expect("replay approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("replay approval pending");
let mut forged_next = gdd.clone();
forged_next.version = 2;
@@ -3669,7 +3666,7 @@ mod tests {
"gdd-approval-00000000-0000-4000-8000-000000000003".to_string();
forged_next.action_fingerprint = "4".repeat(64);
forged_next.fingerprint = plan_gdd_fingerprint(&forged_next).expect("next fingerprint");
let stale = create_plan_gdd_approval_pending_at(&root, &forged_next)
let stale = create_plan_gdd_approval_pending_for_test(&root, &forged_next)
.expect_err("a non-latest GDD cannot receive an approval card");
assert_eq!(stale.code(), "PLAN_STALE_APPROVAL");
@@ -3684,7 +3681,7 @@ mod tests {
)
.expect("commit approval receipt");
assert_eq!(decision.outcome, "committed");
let after_receipt = create_plan_gdd_approval_pending_at(&root, &gdd)
let after_receipt = create_plan_gdd_approval_pending_for_test(&root, &gdd)
.expect_err("a receipt must close awaiting_decision recreation");
assert_eq!(after_receipt.code(), "PLAN_STALE_APPROVAL");
cleanup_fixture(root);
@@ -3698,7 +3695,7 @@ mod tests {
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
let task_path =
game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID);
@@ -3738,7 +3735,7 @@ mod tests {
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
let _config_guard = crate::tests::write_test_local_config(
r#"{"planning":{"capabilityEnabled":false}}"#.to_string(),
);
@@ -3802,7 +3799,7 @@ mod tests {
assert_eq!(blocker.status, "needs-reconciliation");
assert!(blocker.summary.contains("审批 pending"));
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
let blocker = plan_gdd_completion_blocker_at_locked(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
@@ -3955,7 +3952,7 @@ mod tests {
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
decide_plan_gdd_at(
&root,
&approval_input(
@@ -4089,7 +4086,7 @@ mod tests {
#[test]
fn a_revision_comment_reaches_the_supervisor_conversation_once() {
let (root, gdd, _root_runtime) = acceptance_gate_fixture(true);
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
decide_plan_gdd_at(
&root,
&approval_input(
@@ -4143,7 +4140,7 @@ mod tests {
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
decide_plan_gdd_at(
&root,
&approval_input(
@@ -4230,7 +4227,8 @@ mod tests {
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd)
.expect("create approval pending");
let first_input = approval_input(
&gdd,
action,
@@ -4461,7 +4459,7 @@ mod tests {
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
let decision_input = approval_input(
&gdd,
"approve",
@@ -475,7 +475,7 @@ fn runtime_asset_import_string_array(
.any(|part| part.eq_ignore_ascii_case(".codex"))
|| reject_sensitive_project_file_read(text).is_err()
{
return Err("localPaths 只能使用受控项目根内的项目相对图片路径".to_string());
return Err("localPaths 只能使用受控项目根内的项目相对资源路径".to_string());
}
}
Ok(text.to_string())
@@ -579,7 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import(
}
}
if !local_paths.is_empty() {
match import_local_project_image_assets_for_agent(root, &local_paths) {
match import_local_project_assets_for_agent(root, &local_paths) {
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
serde_json::json!({
"id": asset.id,
@@ -1356,7 +1356,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"读取当前登录账户的云端/网页素材库静态图片,以及当前项目已绑定网页画布 project.resources 图片安全投影;不返回 URL、objectKey、签名地址或凭据。"
}
"canvas.asset_import" => {
"把账户素材库/绑定网页项目画布中的 assetIdresourceId)或项目内 localPaths 图片导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地图片先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。"
"把账户素材库/绑定网页项目画布中的 assetIdresourceId)或项目内 localPaths 资源导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地资源先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。"
}
"project.index" => "刷新并读取有界仓库启动上下文。",
"project.search" => "在项目文本文件中做有界字面量搜索。",
+222 -107
View File
@@ -136,23 +136,20 @@ fn normalize_external_editor_api_key(value: &str) -> Result<String, String> {
fn private_external_editor_api_credentials_from_file_at(
path: &Path,
) -> Result<Option<ExternalEditorApiCredentials>, String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!("读取本机陶泥儿开发者 Key 配置失败:{error}"));
}
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("本机陶泥儿开发者 Key 配置必须是普通文件".to_string());
if !crate::prepare_game_creator_private_path_for_read(path, false, "本机陶泥儿开发者 Key 文件")?
{
return Ok(None);
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?;
if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES {
return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string());
}
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user(path, false, false)?;
let content = fs::read_to_string(path)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?;
let content = crate::read_game_creator_private_file_to_string(
path,
"本机陶泥儿开发者 Key 配置",
PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES,
)?;
let parsed = serde_json::from_str::<PrivateExternalEditorApiKeyFile>(&content)
.map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?;
let api_key = normalize_external_editor_api_key(&parsed.api_key)?;
@@ -162,6 +159,22 @@ fn private_external_editor_api_credentials_from_file_at(
.as_deref()
.unwrap_or(DEFAULT_CANVAS_SYNC_API_BASE_URL),
)?;
let expected_fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes()));
let actual_fingerprint = path
.file_name()
.and_then(|value| value.to_str())
.and_then(|value| {
value
.strip_prefix(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX)
.and_then(|value| value.strip_suffix(".json"))
})
.filter(|value| value.len() == 16 && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
.ok_or_else(|| "本机陶泥儿开发者 Key 文件名身份无效,请重新登录客户端后重试".to_string())?;
if !actual_fingerprint.eq_ignore_ascii_case(&expected_fingerprint[..16]) {
return Err(
"本机陶泥儿开发者 Key 文件身份与服务器地址不一致,请重新登录客户端后重试".to_string(),
);
}
Ok(Some(ExternalEditorApiCredentials {
api_base_url,
api_key,
@@ -171,18 +184,13 @@ fn private_external_editor_api_credentials_from_file_at(
fn unique_private_external_editor_api_credentials_at(
directory: &Path,
) -> Result<Option<ExternalEditorApiCredentials>, String> {
let metadata = match fs::symlink_metadata(directory) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!("读取本机陶泥儿开发者 Key 目录失败:{error}"));
}
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string());
if !crate::prepare_game_creator_private_path_for_read(
directory,
true,
"本机陶泥儿开发者 Key 目录",
)? {
return Ok(None);
}
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user(directory, true, false)?;
let mut candidates = fs::read_dir(directory)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))?
.filter_map(Result::ok)
@@ -219,35 +227,15 @@ fn ensure_plain_private_external_editor_directory(
path: &Path,
label: &str,
) -> Result<bool, String> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录"));
}
Ok(false)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let created = match fs::create_dir(path) {
Ok(()) => true,
Err(create_error) if create_error.kind() == std::io::ErrorKind::AlreadyExists => {
false
}
Err(create_error) => {
return Err(format!(
"创建本机陶泥儿开发者凭据{label}失败:{create_error}"
));
}
};
let metadata = fs::symlink_metadata(path).map_err(|metadata_error| {
format!("读取本机陶泥儿开发者凭据{label}失败:{metadata_error}")
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录"));
}
Ok(created)
}
Err(error) => Err(format!("读取本机陶泥儿开发者凭据{label}失败:{error}")),
let label = format!("本机陶泥儿开发者凭据{label}");
let created = crate::ensure_game_creator_private_directory_tree(path, &label)?;
#[cfg(windows)]
if !created {
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path, true, true,
)?;
}
Ok(created)
}
/// Prepares the exact private directory before a one-time remote developer key
@@ -264,15 +252,11 @@ fn prepare_private_external_editor_api_credentials_parent_dir_at(
.parent()
.ok_or_else(|| "本机陶泥儿开发者凭据配置缺少上级目录".to_string())?;
ensure_plain_private_external_editor_directory(container, "上级目录")?;
let parent_created = ensure_plain_private_external_editor_directory(parent, "目录")?;
ensure_plain_private_external_editor_directory(parent, "目录")?;
#[cfg(windows)]
if parent_created {
initialize_windows_game_creator_directory_owner_for_current_user(parent)?;
} else {
secure_windows_game_creator_path_for_current_user(parent, true, true)?;
}
secure_windows_game_creator_path_for_current_user_with_auto_elevation(parent, true, true)?;
#[cfg(unix)]
if parent_created {
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("收紧本机陶泥儿开发者凭据目录权限失败:{error}"))?;
@@ -301,8 +285,19 @@ fn write_private_external_editor_api_credentials_at(
if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() {
return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string());
}
if path.exists() {
return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string());
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(
"本机陶泥儿开发者 Key 目标必须是普通文件,不能是链接或其他对象".to_string(),
);
}
return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("读取本机陶泥儿开发者 Key 目标失败:{error}"));
}
}
let body = serde_json::to_string_pretty(&PrivateExternalEditorApiKeyFile {
api_key: credentials.api_key.clone(),
@@ -325,9 +320,19 @@ fn write_private_external_editor_api_credentials_at(
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&temporary)
.map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?;
crate::harden_new_game_creator_private_path(&temporary, false, "本机陶泥儿开发者 Key 临时文件")
.map_err(|error| {
let _ = fs::remove_file(&temporary);
format!("初始化本机陶泥儿开发者 Key 临时文件安全权限失败:{error}")
})?;
let write_result = file
.write_all(format!("{body}\n").as_bytes())
.and_then(|_| file.sync_all());
@@ -336,8 +341,6 @@ fn write_private_external_editor_api_credentials_at(
let _ = fs::remove_file(&temporary);
return Err(format!("写入本机陶泥儿开发者 Key 临时文件失败:{error}"));
}
#[cfg(windows)]
initialize_windows_game_creator_file_owner_for_current_user(&temporary)?;
match fs::hard_link(&temporary, path) {
Ok(()) => {
let _ = fs::remove_file(&temporary);
@@ -353,7 +356,14 @@ fn write_private_external_editor_api_credentials_at(
}
}
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user(path, false, true)?;
if let Err(error) =
secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)
{
let _ = fs::remove_file(path);
return Err(format!(
"复核本机陶泥儿开发者 Key 文件安全权限失败:{error}"
));
}
Ok(())
}
@@ -474,11 +484,10 @@ pub(crate) fn upload_local_asset_at(
let relative_path = format!("assets/uploads/{asset_id}-{safe_name}");
let absolute_path = root.join(&relative_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建上传目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "上传目录")?;
prepare_game_creator_private_path_for_read(parent, true, "上传目录")?;
}
fs::write(&absolute_path, bytes)
.map_err(|error| format!("写入上传文件失败:{}: {error}", absolute_path.display()))?;
crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?;
register_local_asset_entry(
root,
@@ -578,13 +587,11 @@ pub(crate) fn import_canvas_export_at(
if !export_path.is_absolute() {
return Err("画板导出 ZIP 路径必须是绝对路径".to_string());
}
crate::prepare_game_creator_user_selected_path_for_read(export_path, false, "画板导出 ZIP")?;
let metadata = fs::symlink_metadata(export_path)
.map_err(|error| format!("读取画板导出 ZIP 失败:{}: {error}", export_path.display()))?;
if metadata.file_type().is_symlink() {
return Err("画板导出 ZIP 不能是符号链接".to_string());
}
if !metadata.is_file() {
return Err("画板导出路径必须是 ZIP 文件".to_string());
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("画板导出路径必须是普通 ZIP 文件".to_string());
}
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
@@ -765,12 +772,10 @@ pub(crate) async fn sync_canvas_project_assets_at(
);
let absolute_path = root.join(&local_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建画板同步目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "画板同步目录")?;
prepare_game_creator_private_path_for_read(parent, true, "画板同步目录")?;
}
fs::write(&absolute_path, &download.bytes).map_err(|error| {
format!("写入画板同步资产失败:{}: {error}", absolute_path.display())
})?;
crate::write_game_creator_private_file(&absolute_path, &download.bytes, "画板同步资产")?;
assets.push(register_local_asset_entry(
root,
&local_path,
@@ -1423,26 +1428,81 @@ pub(crate) fn percent_encode_query_component(value: &str) -> String {
pub(crate) fn infer_file_extension(source: Option<&str>, media_type: &str) -> &'static str {
if let Some(source) = source {
let path = source.split('?').next().unwrap_or(source);
if path.ends_with(".png") {
return "png";
}
if path.ends_with(".jpg") || path.ends_with(".jpeg") {
return "jpg";
}
if path.ends_with(".webp") {
return "webp";
}
if path.ends_with(".gif") {
return "gif";
}
if path.ends_with(".mp3") {
return "mp3";
}
if path.ends_with(".wav") {
return "wav";
}
if path.ends_with(".mp4") {
return "mp4";
match Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("png") => return "png",
Some("jpg" | "jpeg") => return "jpg",
Some("webp") => return "webp",
Some("gif") => return "gif",
Some("svg") => return "svg",
Some("avif") => return "avif",
Some("bmp") => return "bmp",
Some("ttf") => return "ttf",
Some("otf") => return "otf",
Some("woff") => return "woff",
Some("woff2") => return "woff2",
Some("mp3") => return "mp3",
Some("wav") => return "wav",
Some("ogg") => return "ogg",
Some("flac") => return "flac",
Some("m4a") => return "m4a",
Some("aac") => return "aac",
Some("opus") => return "opus",
Some("mp4") => return "mp4",
Some("webm") => return "webm",
Some("mov") => return "mov",
Some("md") => return "md",
Some("markdown") => return "markdown",
Some("mdx") => return "mdx",
Some("txt") => return "txt",
Some("json") => return "json",
Some("yaml") => return "yaml",
Some("yml") => return "yml",
Some("toml") => return "toml",
Some("csv") => return "csv",
Some("ini") => return "ini",
Some("conf") => return "conf",
Some("xml") => return "xml",
Some("html") => return "html",
Some("htm") => return "htm",
Some("css") => return "css",
Some("scss") => return "scss",
Some("less") => return "less",
Some("js") => return "js",
Some("mjs") => return "mjs",
Some("cjs") => return "cjs",
Some("ts") => return "ts",
Some("tsx") => return "tsx",
Some("gd") => return "gd",
Some("rs") => return "rs",
Some("py") => return "py",
Some("go") => return "go",
Some("java") => return "java",
Some("kt") => return "kt",
Some("kts") => return "kts",
Some("c") => return "c",
Some("cc") => return "cc",
Some("cpp") => return "cpp",
Some("h") => return "h",
Some("hpp") => return "hpp",
Some("cs") => return "cs",
Some("swift") => return "swift",
Some("php") => return "php",
Some("rb") => return "rb",
Some("lua") => return "lua",
Some("sh") => return "sh",
Some("bash") => return "bash",
Some("zsh") => return "zsh",
Some("sql") => return "sql",
Some("graphql") => return "graphql",
Some("gql") => return "gql",
Some("vue") => return "vue",
Some("svelte") => return "svelte",
_ => {}
}
}
match media_type.split(';').next().unwrap_or(media_type).trim() {
@@ -1450,9 +1510,29 @@ pub(crate) fn infer_file_extension(source: Option<&str>, media_type: &str) -> &'
"image/jpeg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/svg+xml" => "svg",
"image/avif" => "avif",
"image/bmp" => "bmp",
"font/ttf" => "ttf",
"font/otf" => "otf",
"font/woff" => "woff",
"font/woff2" => "woff2",
"audio/mpeg" => "mp3",
"audio/wav" | "audio/x-wav" => "wav",
"audio/ogg" => "ogg",
"audio/flac" => "flac",
"audio/mp4" => "m4a",
"audio/aac" => "aac",
"audio/opus" => "opus",
"video/mp4" => "mp4",
"video/webm" => "webm",
"video/quicktime" => "mov",
"application/json" => "json",
"application/yaml" => "yaml",
"application/xml" => "xml",
"text/html" => "html",
"text/css" => "css",
"text/plain" => "txt",
_ => "bin",
}
}
@@ -1523,13 +1603,18 @@ pub(crate) fn extract_canvas_export_zip_files(
let local_relative_path = format!("{import_relative_root}/{normalized_relative}");
let target_path = resolve_local_project_path(root, &local_relative_path)?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建画板导入目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "画板导入目录")?;
prepare_game_creator_private_path_for_read(parent, true, "画板导入目录")?;
}
let mut output = File::create(&target_path)
.map_err(|error| format!("写入画板导入文件失败:{}: {error}", target_path.display()))?;
std::io::copy(&mut entry, &mut output)
let entry_size = entry.size();
let mut bytes = Vec::with_capacity(entry_size.min(MAX_CANVAS_EXPORT_BYTES as u64) as usize);
std::io::Read::take(&mut entry, entry_size + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("解压画板导出文件失败:{}: {error}", target_path.display()))?;
if bytes.len() as u64 != entry_size {
return Err("画板导出 ZIP 条目读取长度不一致".to_string());
}
crate::write_game_creator_private_file(&target_path, &bytes, "画板导入文件")?;
copied_files.push(normalized_relative);
}
if copied_files.is_empty() {
@@ -1712,6 +1797,14 @@ mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn infer_file_extension_preserves_supported_local_resource_extensions() {
assert_eq!(infer_file_extension(Some("game/Index.HTML"), "text/html"), "html");
assert_eq!(infer_file_extension(Some("assets/theme.MP3"), "audio/mpeg"), "mp3");
assert_eq!(infer_file_extension(Some("assets/font.woff2"), "font/woff2"), "woff2");
assert_eq!(infer_file_extension(None, "application/json"), "json");
}
#[test]
fn platform_session_maps_external_shaped_calls_to_first_party_routes() {
assert_eq!(
@@ -1807,11 +1900,16 @@ mod tests {
{
let root = tempfile::tempdir().expect("temp dir");
let directory = root.path().join("config").join("genarrative");
let first_path = directory.join("external-editor-api-0000000000000001.json");
let first = ExternalEditorApiCredentials {
api_base_url: "https://dev.genarrative.world".to_string(),
api_key: "tnr_sk_headless_fixture_1".to_string(),
};
let first_path = directory.join(
private_external_editor_api_key_path_for_base_url(&first.api_base_url)
.expect("first credential path")
.file_name()
.expect("first credential filename"),
);
write_private_external_editor_api_credentials_at(&first_path, &first)
.expect("write first private credential");
let recovered = unique_private_external_editor_api_credentials_at(&directory)
@@ -1820,11 +1918,16 @@ mod tests {
assert_eq!(recovered.api_base_url, first.api_base_url);
assert_eq!(recovered.api_key, first.api_key);
let second_path = directory.join("external-editor-api-0000000000000002.json");
let second = ExternalEditorApiCredentials {
api_base_url: "https://www.genarrative.world".to_string(),
api_key: "tnr_sk_headless_fixture_2".to_string(),
};
let second_path = directory.join(
private_external_editor_api_key_path_for_base_url(&second.api_base_url)
.expect("second credential path")
.file_name()
.expect("second credential filename"),
);
write_private_external_editor_api_credentials_at(&second_path, &second)
.expect("write second private credential");
let error = match unique_private_external_editor_api_credentials_at(&directory) {
@@ -1856,11 +1959,17 @@ mod tests {
#[test]
fn newly_created_private_external_editor_credentials_directory_is_owned_by_token_user() {
let root = tempfile::tempdir().expect("temp dir");
let credential_file_name =
private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world")
.expect("credential path")
.file_name()
.expect("credential filename")
.to_owned();
let path = root
.path()
.join("config")
.join("genarrative")
.join("external-editor-api-test.json");
.join(credential_file_name);
prepare_private_external_editor_api_credentials_parent_dir_at(&path)
.expect("prepare private credential directory");
@@ -1889,7 +1998,13 @@ mod tests {
"fixture must reproduce the inherited ACL rejection"
);
let path = parent.join("external-editor-api-test.json");
let credential_file_name =
private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world")
.expect("credential path")
.file_name()
.expect("credential filename")
.to_owned();
let path = parent.join(credential_file_name);
prepare_private_external_editor_api_credentials_parent_dir_at(&path)
.expect("current-user directory should be tightened locally before remote creation");
secure_windows_game_creator_path_for_current_user(&parent, true, false)

Some files were not shown because too many files have changed in this diff Show More