mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Compare commits
333
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5207d6f89b | ||
|
|
20354e388f | ||
|
|
4111108fc4 | ||
|
|
fd3c7f06aa | ||
|
|
9e8fd7b7f7 | ||
|
|
bfbde4dea7 | ||
|
|
31f54d8cc7 | ||
|
|
578f55aac2 | ||
|
|
f2b9ef6561 | ||
|
|
c4ed71f9ae | ||
|
|
3cdf8dbd91 | ||
|
|
7ead478adf | ||
|
|
01fddd508b | ||
|
|
d5272e17c8 | ||
|
|
fea401fa6f | ||
|
|
9eca20c760 | ||
|
|
013caa0859 | ||
|
|
c9446388ef | ||
|
|
3dd2b82bf0 | ||
|
|
1793d22f00 | ||
|
|
eabc0a2660 | ||
|
|
89962c4e98 | ||
|
|
7fde39d38f | ||
|
|
d30147226a | ||
|
|
96f223e61d | ||
|
|
0d526b6c4e | ||
|
|
03d5655717 | ||
|
|
2711e55524 | ||
|
|
142b79f19a | ||
|
|
90cf3f3f1f | ||
|
|
a170f2158a | ||
|
|
d04a260c69 | ||
|
|
e7d11ed91f | ||
|
|
c3e4c641fb | ||
|
|
519a4acc11 | ||
|
|
4dfab0faf5 | ||
|
|
4622fd295a | ||
|
|
14c3585759 | ||
|
|
324147354e | ||
|
|
8c196cc72e | ||
|
|
c6031016b1 | ||
|
|
7c9c4ddb2c | ||
|
|
69999fe72e | ||
|
|
8479846661 | ||
|
|
26ba59a21c | ||
|
|
0e03a900b5 | ||
|
|
7d068b874c | ||
|
|
56b843150c | ||
|
|
5c69bbac54 | ||
|
|
1a0f26d06d | ||
|
|
61f8c4e106 | ||
|
|
c3f0838207 | ||
|
|
5e838e4342 | ||
|
|
07d3ffdd00 | ||
|
|
0dee47f5f3 | ||
|
|
e4b673621e | ||
|
|
f11b121a50 | ||
|
|
8b083a107c | ||
|
|
46baa69def | ||
|
|
5b9b54596a | ||
|
|
f4564fd810 | ||
|
|
cb220f64e5 | ||
|
|
a2015d29ca | ||
|
|
29beca2922 | ||
|
|
c55bcc1f8d | ||
|
|
2abcc7637e | ||
|
|
78a67947ec | ||
|
|
465d7e7dd9 | ||
|
|
ffc0bec51b | ||
|
|
9882d1349a | ||
|
|
18c8fba59f | ||
|
|
6075673d9e | ||
|
|
3b60665854 | ||
|
|
d59ed66dbc | ||
|
|
35dadd8767 | ||
|
|
1b7ea17021 | ||
|
|
a9332b6954 | ||
|
|
d1a27868e2 | ||
|
|
d1ad88853c | ||
|
|
25e41cd046 | ||
|
|
87907cb2a2 | ||
|
|
9bb3435e62 | ||
|
|
59cc24b3ab | ||
|
|
50bdcd6088 | ||
|
|
250b19c052 | ||
|
|
cd1d559097 | ||
|
|
d821485c19 | ||
|
|
89d987fb2d | ||
|
|
be3fd8a425 | ||
|
|
1641c3584d | ||
|
|
1356b14d2f | ||
|
|
34ef4b0338 | ||
|
|
e0cec93a9e | ||
|
|
6808c5a9e0 | ||
|
|
65ff7dab74 | ||
|
|
32a6365d87 | ||
|
|
7d069d44dc | ||
|
|
8f3ee06fd3 | ||
|
|
4579185478 | ||
|
|
11876a0d61 | ||
|
|
eafc8177e9 | ||
|
|
337f2d2cb2 | ||
|
|
9666c96f3a | ||
|
|
7e271b1164 | ||
|
|
8d03609fa1 | ||
|
|
d3fa67857c | ||
|
|
f3b5febe90 | ||
|
|
a63c2b92e4 | ||
|
|
8467e1acb2 | ||
|
|
856d899bc9 | ||
|
|
c5310be184 | ||
|
|
21980b4234 | ||
|
|
a062240d9f | ||
|
|
d56d29f6fc | ||
|
|
543633f143 | ||
|
|
162ee3edbe | ||
|
|
19f8d5b001 | ||
|
|
e209cf37c8 | ||
|
|
a1d7886609 | ||
|
|
3e5607272c | ||
|
|
0efb371b9c | ||
|
|
cd360b9d84 | ||
|
|
9683f50271 | ||
|
|
d305f13d78 | ||
|
|
8f4ece4453 | ||
|
|
17d681fa2d | ||
|
|
68ec3c90e1 | ||
|
|
0b80ee3106 | ||
|
|
57a1a66db8 | ||
|
|
e19a65af23 | ||
|
|
4d733ecfda | ||
|
|
fb7e7c381e | ||
|
|
c906f3cd45 | ||
|
|
bdf3f96684 | ||
|
|
fb6a82c8e3 | ||
|
|
4eeb23bfce | ||
|
|
8016f8be98 | ||
|
|
917061e58b | ||
|
|
7be6fee2d5 | ||
|
|
3a97d8b439 | ||
|
|
1a3aa44cb3 | ||
|
|
1a684d216c | ||
|
|
f8853733d3 | ||
|
|
006b361d71 | ||
|
|
61e1e03bf9 | ||
|
|
5636fed560 | ||
|
|
cf7bbae959 | ||
|
|
06805ca7fc | ||
|
|
37bcff7fb9 | ||
|
|
50277a2fce | ||
|
|
a548df7f55 | ||
|
|
fec7b1eebb | ||
|
|
b77c9fac60 | ||
|
|
75abe61e97 | ||
|
|
f91bad3ca1 | ||
|
|
b7df42b3cd | ||
|
|
a32fbea44d | ||
|
|
72c81815bc | ||
|
|
188c373baa | ||
|
|
527eee4b4c | ||
|
|
d736354248 | ||
|
|
980008de88 | ||
|
|
1115efbc9f | ||
|
|
04a8b3542a | ||
|
|
14aae2c4c0 | ||
|
|
d8ba78d183 | ||
|
|
c255459219 | ||
|
|
653439a3b4 | ||
|
|
35bdacd24b | ||
|
|
023f20e974 | ||
|
|
76afa1a77c | ||
|
|
28067f69ff | ||
|
|
f90b50ec0a | ||
|
|
d2da65b8c4 | ||
|
|
13b87dd4e9 | ||
|
|
4a6e55ff08 | ||
|
|
c57193ea4f | ||
|
|
af12885528 | ||
|
|
56b68e2701 | ||
|
|
6bf8c576eb | ||
|
|
b35f38962f | ||
|
|
ecf1e37a79 | ||
|
|
607025713e | ||
|
|
9f789f9abd | ||
|
|
ac30722a7c | ||
|
|
c4cf1388e4 | ||
|
|
1f2bf0b93f | ||
|
|
422fd16d9c | ||
|
|
38106c9200 | ||
|
|
2917b96263 | ||
|
|
9a9323f3be | ||
|
|
90f201ee25 | ||
|
|
641991840d | ||
|
|
106fe593fd | ||
|
|
944321e2b6 | ||
|
|
8b2ca911a1 | ||
|
|
020b388126 | ||
|
|
c5ef5ed10f | ||
|
|
69ede811cc | ||
|
|
712fcf10f0 | ||
|
|
5c7f6f0801 | ||
|
|
96465dde21 | ||
|
|
eaf665edff | ||
|
|
735a6e5998 | ||
|
|
6817f58e52 | ||
|
|
3dea01d441 | ||
|
|
2206a95f12 | ||
|
|
dbacae668a | ||
|
|
d23e03b20a | ||
|
|
42e9c7ce2e | ||
|
|
07f25c1ca4 | ||
|
|
6038a6fbf6 | ||
|
|
a5f772b718 | ||
|
|
23f0a011c4 | ||
|
|
6c291e8c3e | ||
|
|
395c5eace2 | ||
|
|
2f7c0613fd | ||
|
|
dd381070f4 | ||
|
|
b1c22c9cfe | ||
|
|
291a03a9ca | ||
|
|
0dd3e5574a | ||
|
|
07291a5f04 | ||
|
|
d58f25580e | ||
|
|
77d92a8eb9 | ||
|
|
d1ab26d6f4 | ||
|
|
ee786a1ca6 | ||
|
|
562cdd3f7e | ||
|
|
422caf9595 | ||
|
|
1fb3f55396 | ||
|
|
d158d19aef | ||
|
|
ebabe620e1 | ||
|
|
24f11e8ecc | ||
|
|
40c87d94c6 | ||
|
|
54b7bc20bb | ||
|
|
4d486b4829 | ||
|
|
2c85351b91 | ||
|
|
b127b607fa | ||
|
|
40891fdc66 | ||
|
|
6e5d404ddd | ||
|
|
c0f4d5ea15 | ||
|
|
3bca7e6fbe | ||
|
|
1bd84d9fdd | ||
|
|
025bd17b2f | ||
|
|
211d9e8ed8 | ||
|
|
8c9bbd2557 | ||
|
|
908b0aa4b9 | ||
|
|
572206843f | ||
|
|
05723cf928 | ||
|
|
9269f039df | ||
|
|
82377396a5 | ||
|
|
85a3db0114 | ||
|
|
922efd90a3 | ||
|
|
25bbd84e1b | ||
|
|
3e81444615 | ||
|
|
3f86a93bdd | ||
|
|
867cf2752c | ||
|
|
129513bde1 | ||
|
|
0feed1a569 | ||
|
|
88cb1507ec | ||
|
|
ad7cb47518 | ||
|
|
b59c001ed9 | ||
|
|
5f25195610 | ||
|
|
04d203959b | ||
|
|
4160c3307e | ||
|
|
fa69e53bdc | ||
|
|
19e77f9313 | ||
|
|
9f8ad33f9d | ||
|
|
e7a9c3f1a7 | ||
|
|
10276daf1e | ||
|
|
f229559c8f | ||
|
|
38f7a5f600 | ||
|
|
ee1352964d | ||
|
|
de30166094 | ||
|
|
460ff51eb3 | ||
|
|
9b0cc7a38e | ||
|
|
6b0a66b3a2 | ||
|
|
a98a77b493 | ||
|
|
dac78f1bbd | ||
|
|
ee07b51489 | ||
|
|
4ff9414f4a | ||
|
|
8da9cac34d | ||
|
|
a63994d0b9 | ||
|
|
5a4a21bab7 | ||
|
|
6423d8df85 | ||
|
|
df659ea523 | ||
|
|
00ba31d044 | ||
|
|
510676e625 | ||
|
|
4f0faa4afe | ||
|
|
e2e514710d | ||
|
|
9539b02d41 | ||
|
|
84974cb155 | ||
|
|
f369bb7c4d | ||
|
|
04213f2cd7 | ||
|
|
560de562bc | ||
|
|
6ce8bcb460 | ||
|
|
a99ed416b0 | ||
|
|
e8a0e2aad3 | ||
|
|
d6a6c2855d | ||
|
|
0614529acc | ||
|
|
eb82ea896d | ||
|
|
dfeeace5ab | ||
|
|
cfc25dc8f1 | ||
|
|
30ac7c3fe6 | ||
|
|
0040a38ce9 | ||
|
|
ae27da71e2 | ||
|
|
b6a2e78aea | ||
|
|
51c5ce1c86 | ||
|
|
9221be649e | ||
|
|
f89cdcc0c4 | ||
|
|
dfc9d1ee28 | ||
|
|
9feb5188a7 | ||
|
|
8a2d067dd0 | ||
|
|
9ae9bc4bc7 | ||
|
|
5bcba146d8 | ||
|
|
7a562055e5 | ||
|
|
fba2fbad2a | ||
|
|
cf2c2dcdaa | ||
|
|
5b707c00ee | ||
|
|
b328f4de7f | ||
|
|
093e5e1b3b | ||
|
|
f71fb49285 | ||
|
|
85020e081b | ||
|
|
9e137c4c51 | ||
|
|
4afe8aba93 | ||
|
|
1b0d257c5b | ||
|
|
9a5854f6b6 | ||
|
|
32f24869c9 | ||
|
|
78e68ef9ea | ||
|
|
46aa95c3ae | ||
|
|
e70cdeaecc | ||
|
|
d7df48e18a | ||
|
|
c3591b1599 | ||
|
|
baf8db1156 |
No files matched your search
@@ -27,3 +27,4 @@
|
||||
*.yml text eol=lf
|
||||
.githooks/** text eol=lf
|
||||
benchmarks/**/*.sh text eol=lf
|
||||
internal/runtime/agent/testdata/perseveration/** -text
|
||||
@@ -19,6 +19,13 @@
|
||||
/docs/DOCS_STANDARD.md @esengine @SivanCola
|
||||
/docs/STUDIO_*.md @esengine @SivanCola
|
||||
/docs/SESSION_MEMORY_RETRIEVAL.md @esengine @SivanCola
|
||||
/docs/*_CONTRACT.md @esengine @SivanCola
|
||||
/docs/TOOL_APPROVAL_MODES.md @esengine @SivanCola
|
||||
/docs/CAPABILITY_DIAGNOSTICS.md @esengine @SivanCola
|
||||
/docs/CODE_SIGNING_POLICY.md @esengine @SivanCola
|
||||
/docs/CHECKPOINTS.md @esengine @SivanCola
|
||||
/docs/EXTENSION_*.md @esengine @SivanCola
|
||||
/docs/SUBAGENT_*.md @esengine @SivanCola
|
||||
/docs/ROADMAP.md @esengine @SivanCola
|
||||
/docs/MIGRATING.md @esengine @SivanCola
|
||||
/docs/design/ @esengine @SivanCola
|
||||
|
||||
@@ -61,8 +61,8 @@ jobs:
|
||||
# its DTOs are generated from internal/ext/extension/protocol, so
|
||||
# both paths must trigger the sdk job.
|
||||
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
|
||||
# The release-note tests render every committed Studio note.
|
||||
if echo "$files" | grep -qE '^(scripts/|release-notes/studio/)'; then scripts=true; fi
|
||||
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
|
||||
@@ -38,6 +38,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
VITE_REMOTE_HOME: https://reasonix.io/remote/
|
||||
run: pnpm build
|
||||
|
||||
- name: Deploy
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
replies:
|
||||
description: 'JSON array of {"issue": 123, "body": "...", "close": true | "not_planned"}'
|
||||
description: 'JSON array of {"issue": 123, "body": "...", "close": true | "not_planned"} or {"discussion": 123, "body": "...", "close": "resolved" | "outdated" | "duplicate"}'
|
||||
required: true
|
||||
type: string
|
||||
interval:
|
||||
@@ -18,6 +18,7 @@ on:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: issue-replies
|
||||
@@ -45,7 +46,42 @@ jobs:
|
||||
return error.status === 429 ||
|
||||
(error.status === 403 && (headers['retry-after'] !== undefined || headers['x-ratelimit-remaining'] === '0'));
|
||||
};
|
||||
for (const { issue, body, close } of replies) {
|
||||
// Discussions have no REST comment API; they are addressed by node id.
|
||||
const replyToDiscussion = async (number, body) => {
|
||||
const q = await github.graphql(
|
||||
`query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){discussion(number:$n){id comments(last:100){nodes{body author{login}}}}}}`,
|
||||
{ owner, repo, n: number });
|
||||
const d = q.repository.discussion;
|
||||
if (!d) throw new Error('no such discussion');
|
||||
if (d.comments.nodes.some((c) => c.author?.login === 'github-actions' && c.body === body)) return false;
|
||||
await github.graphql(`mutation($id:ID!,$body:String!){addDiscussionComment(input:{discussionId:$id,body:$body}){comment{id}}}`, { id: d.id, body });
|
||||
return true;
|
||||
};
|
||||
const closeReasons = { resolved: 'RESOLVED', outdated: 'OUTDATED', duplicate: 'DUPLICATE' };
|
||||
const closeDiscussion = async (number, reason) => {
|
||||
const q = await github.graphql(
|
||||
`query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){discussion(number:$n){id closed}}}`,
|
||||
{ owner, repo, n: number });
|
||||
const d = q.repository.discussion;
|
||||
if (d.closed) return;
|
||||
await github.graphql(`mutation($id:ID!,$r:DiscussionCloseReason!){closeDiscussion(input:{discussionId:$id,reason:$r}){discussion{id}}}`, { id: d.id, r: reason });
|
||||
};
|
||||
for (const { issue, discussion, body, close } of replies) {
|
||||
if (Number.isInteger(discussion) && typeof body === 'string' && body.trim() !== '') {
|
||||
try {
|
||||
const posted = await replyToDiscussion(discussion, body);
|
||||
if (close !== undefined && close !== false) {
|
||||
const reason = closeReasons[close];
|
||||
if (!reason) throw new Error(`unknown close reason ${close}`);
|
||||
await closeDiscussion(discussion, reason);
|
||||
}
|
||||
core.info(`discussion #${discussion}: ${posted ? 'replied' : 'already replied'}${close ? ', closed' : ''}`);
|
||||
} catch (error) {
|
||||
failed.push(`discussion ${discussion}: ${error.message}`);
|
||||
}
|
||||
await sleep(Math.max(1, Number(process.env.INTERVAL) || 20) * 1000);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isInteger(issue) || typeof body !== 'string' || body.trim() === '') {
|
||||
failed.push(`${issue}: malformed entry`);
|
||||
continue;
|
||||
|
||||
@@ -979,6 +979,7 @@ jobs:
|
||||
|
||||
# Deltas are served from the mirror only, so a release that cannot reach
|
||||
# it does not offer one: a client would fetch an index nobody uploaded.
|
||||
# The same gate puts the mirror ahead of GitHub for every full artifact.
|
||||
- name: Generate manifest
|
||||
working-directory: desktop
|
||||
env:
|
||||
@@ -1117,9 +1118,39 @@ jobs:
|
||||
needs: [resolve, publish]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Owner approval before anything reaches npm or the tap: the switch alone
|
||||
# would otherwise publish on every studio tag.
|
||||
environment: studio-cli-channels
|
||||
# id-token lets npm publish --provenance attach a Sigstore attestation; the
|
||||
# registry token still authenticates the publish itself.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
# A job naming a missing environment gets an unprotected one, so the
|
||||
# gate is verified rather than assumed; the tokens below exist only in it.
|
||||
- name: Require the approval gate on studio-cli-channels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
resp="$(gh api -i "repos/$REPO/environments/studio-cli-channels" 2>&1)"
|
||||
status="$(printf '%s\n' "$resp" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
|
||||
case "$status" in
|
||||
200) ;;
|
||||
404)
|
||||
echo "::error::environment studio-cli-channels does not exist"
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "::error::reading environment studio-cli-channels as ${GITHUB_ACTOR} failed with HTTP ${status:-unknown}"
|
||||
exit 1 ;;
|
||||
esac
|
||||
reviewers="$(printf '%s\n' "$resp" | sed '1,/^\r\{0,1\}$/d' | jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length] | add // 0')"
|
||||
if ! [[ "$reviewers" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::environment studio-cli-channels has no required reviewers (or its protection rules were unreadable: '${reviewers}'); refusing to publish"
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
@@ -1133,7 +1164,7 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.STUDIO_NPM_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: node npm/build.mjs "$VERSION" --publish
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -1144,7 +1175,7 @@ jobs:
|
||||
- name: Update the Homebrew cask
|
||||
if: needs.resolve.outputs.candidate != 'true'
|
||||
env:
|
||||
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
TAP_TOKEN: ${{ secrets.STUDIO_HOMEBREW_TAP_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
|
||||
@@ -205,7 +205,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
|
||||
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
|
||||
[Tool contract](./docs/TOOL_CONTRACT.md) · [Moving from 1.x to 2.x](./docs/MIGRATING.md) ·
|
||||
[2.x roadmap](./docs/ROADMAP.md)
|
||||
- **Extension development:** [Extensions](./docs/EXTENSIONS.md) ·
|
||||
- **Extension development:** [Skills](./docs/SKILLS.md) · [Extensions](./docs/EXTENSIONS.md) ·
|
||||
[Plugin packages and Manifest v1](./docs/PLUGIN_PACKAGES.md) ·
|
||||
[Extension Protocol](./docs/EXTENSION_PROTOCOL.md) ·
|
||||
[Go SDK and starter](./sdk/go/README.md)
|
||||
@@ -232,10 +232,10 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
|
||||
| **merge-order-check** | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** |
|
||||
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
|
||||
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
|
||||
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
|
||||
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
+4
-4
@@ -214,10 +214,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
|
||||
| **merge-order-check** | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** |
|
||||
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
|
||||
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
|
||||
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
|
||||
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
+57
-6
@@ -56,9 +56,10 @@ Supported boundaries include:
|
||||
- Sandbox behavior for built-in shell execution where the platform supports it.
|
||||
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
|
||||
headers, and credential-store fallback files.
|
||||
- HTTP `serve` protections for the unauthenticated local server, including
|
||||
localhost binding assumptions, JSON-only state-changing requests, and CORS
|
||||
restrictions.
|
||||
- HTTP `serve` protections, including the default per-launch token, the launch
|
||||
token every state-changing request requires when authentication is opted
|
||||
out, localhost binding assumptions, JSON-only state-changing requests, and
|
||||
CORS restrictions.
|
||||
- Desktop and bot session isolation, including per-workspace session metadata
|
||||
and configured bot allowlists.
|
||||
- Updater, install, and release verification paths.
|
||||
@@ -67,10 +68,10 @@ The following are normally treated as trusted local/operator-controlled inputs
|
||||
unless another bug lets an untrusted actor supply them:
|
||||
|
||||
- CLI arguments and text typed directly by the local user.
|
||||
- Project configuration files intentionally loaded from the current workspace.
|
||||
- The user's own configuration under the Reasonix home directory.
|
||||
- Explicit `@path` references supplied by the local user to attach local files.
|
||||
- MCP servers, language servers, hooks, and slash commands installed or enabled
|
||||
by the local user.
|
||||
- MCP servers, language servers, hooks, and slash commands installed, enabled,
|
||||
or approved by the local user.
|
||||
- Provider base URLs and model names configured by the local user.
|
||||
|
||||
The following can be security issues when reachable by an untrusted actor or
|
||||
@@ -89,6 +90,56 @@ when they bypass the intended boundary:
|
||||
- Trusting unverified update artifacts, plugin definitions, or downloaded
|
||||
binaries.
|
||||
|
||||
## Project Configuration
|
||||
|
||||
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
|
||||
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
|
||||
repository is not a decision to trust its author, so these files may narrow
|
||||
what the user configured and never widen it.
|
||||
|
||||
| Setting | What a workspace file may do |
|
||||
| --- | --- |
|
||||
| `sandbox.bash` | Move `off` to `enforce`, never back |
|
||||
| `sandbox.network` | Move `true` to `false`, never back |
|
||||
| `sandbox.forbid_read`, `sandbox.denied_domains` | Add to the user's lists |
|
||||
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
|
||||
| `sandbox.host_authorities`, a user's `sandbox.allowed_domains` | Nothing |
|
||||
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
|
||||
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
|
||||
| `desktop.default_tool_approval_mode` | Nothing |
|
||||
| `[network]` proxy settings | Nothing |
|
||||
| `[[providers]]`, `tools.system_one` endpoints | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
|
||||
|
||||
An "always allow" answer, or extra write access, granted in a workspace is
|
||||
stored under the Reasonix home, not in the workspace.
|
||||
|
||||
Programs a workspace names for the host to run stay off until the user
|
||||
approves them for that workspace:
|
||||
|
||||
- Hooks, language server commands, `tools.search.rg_path`,
|
||||
`tools.shell.path`, `[browser]` and `tools.system_one.laya.python` are
|
||||
approved with `reasonix trust`, or for hooks by saving them from the hooks
|
||||
editor.
|
||||
- An approval is stored under the Reasonix home with a digest of the
|
||||
declaration and of the workspace files it names; any change needs approval
|
||||
again. Approvals and grants belong to one workspace folder.
|
||||
- Hooks and language servers are checked against their approval again before
|
||||
each start, and refused if a file they name changed, appeared or now
|
||||
resolves elsewhere.
|
||||
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
|
||||
`[browser]`, `laya.python`) may not point into the workspace, an
|
||||
`allow_write` root, a temporary directory or a toolchain cache the bash jail
|
||||
leaves writable, and may not climb with `..`; its approval covers the
|
||||
program file's content.
|
||||
- `tools.system_one.laya.local` is read from the user's configuration only.
|
||||
- Without an interactive frontend they stay off.
|
||||
- MCP servers a workspace declares stay off until the user enables them for
|
||||
that workspace.
|
||||
|
||||
A project value that is not applied is reported as a load warning with the
|
||||
reason. A way for workspace files to widen any of the above without the
|
||||
user's approval is a security issue.
|
||||
|
||||
## `@` File References
|
||||
|
||||
Reasonix supports `@path` references so users can include local files and images
|
||||
|
||||
+41
-36
@@ -7,9 +7,9 @@ SWE-bench Verified mode:
|
||||
[`cmd/e2ebench`](../cmd/e2ebench/main.go). It runs each task against a real
|
||||
provider and emits a markdown + JSON report (accuracy, cache-hit rate, token
|
||||
use, cost) suitable for pasting into a PR.
|
||||
- `context-maintenance-e2e/` — a standalone seed → resume → comprehension
|
||||
harness that A/B-compares cold-restart cache behavior with and without
|
||||
context pruning.
|
||||
- `context-maintenance-e2e/` — a standalone seed → resume → continue smoke that
|
||||
checks a session seeds below the compaction trigger, resumes without paying
|
||||
for a summary, and folds exactly once past it.
|
||||
- `compaction/` — CompactionBench: grows a session one generation at a time and
|
||||
folds it after each, measuring what repeated compaction costs and what it
|
||||
loses. See [CompactionBench](#compactionbench) below.
|
||||
@@ -426,13 +426,14 @@ own outcome).
|
||||
| `-bin` | `reasonix` | Path to the reasonix binary. |
|
||||
| `-model` | *(config default)* | Provider/model name. |
|
||||
| `-profile` | `baseline` | Tool-surface/runtime tier: `baseline` \| `economy` \| `balanced` \| `delivery`. All but `baseline` append `--profile <tier>` to the agent invocation; `baseline` passes no flag (byte-identical legacy control, behaviorally `balanced`). Economy starts with the core tool set and pays `connect_tool_source` rounds plus prefix resets to grow it — the report's Tool surface line prices that trade. |
|
||||
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`; `none` \| `all`. |
|
||||
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`, `upstream` and `recall-search`; `none` \| `all`. |
|
||||
| `-out` | *(stdout)* | Write the markdown report here. |
|
||||
| `-json` | *(none)* | Write the JSON report here (optional). |
|
||||
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
|
||||
| `-force-planner` | `false` | Suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate. Use for the "with planner" arm of an A/B; results carry `plan_forced` so arms are only comparable with equal forcing. |
|
||||
| `-anchor` | `blind` | Suite mode: which hypothesis the agent holds before it looks at anything — `blind` (none, the control) \| `correct` \| `wrong`. The seeded arms prefix each prompt with the task's authored seed and **skip** tasks that have none, so an unseeded control run never lands in a seeded denominator. Results carry `anchor`. See [Anchor resistance](#anchor-resistance). |
|
||||
| `-cache` | `cold` | Suite mode: `cold` runs each task as a fresh session (the fair cross-agent comparison arm); `warm` primes the provider prefix cache with a one-step run in the same workdir first, measuring the long-lived-session steady state. Never mix arms in one report — compare them with `-mode compare cold.json warm.json`. |
|
||||
| `-policy` | *(none)* | Suite mode: experiment arm. `memory-off` hides the memory store, which is the MemoryBench counterfactual arm; the empty default runs the baseline. |
|
||||
| `-budget` | `800000` | Abort once total tokens cross this (`0` = no cap). Remaining tasks are reported as skipped. |
|
||||
| `-meter` | *(off)* | Suite mode: route the benchmarked provider through the neutral measuring proxy, using this `config.toml` as the source. Spend is then counted at the request boundary instead of trusted from the harness. See [Neutral metering](#neutral-metering). |
|
||||
| `-faults` | *(none)* | Suite mode: inject provider failures through the meter — absolute indices (`3:429`) and/or a cadence that scales with the run (`every:5:500`). Requires `-meter`. See [Fault recovery](#fault-recovery). |
|
||||
@@ -541,33 +542,32 @@ writer.
|
||||
|
||||
## context-maintenance-e2e
|
||||
|
||||
This harness measures what happens when a long session goes idle past the
|
||||
provider's cache TTL and then resumes: it A/B-compares cold-restart miss tokens
|
||||
with and without pruning, and checks that the agent re-reads a file behind a
|
||||
prune placeholder instead of hallucinating.
|
||||
A cost-capped smoke for content-driven maintenance, in three legs over one state
|
||||
directory: seed a session just below the compaction trigger, load it back and
|
||||
check nothing is re-derived, then grow it past the trigger and check exactly one
|
||||
summary lands.
|
||||
|
||||
It is hardcoded to the `deepseek-v4-flash` model at `https://api.deepseek.com`
|
||||
and requires the `DEEPSEEK_API_KEY` environment variable.
|
||||
Seed and resume take no provider; `continue` samples and needs
|
||||
`DEEPSEEK_API_KEY`. The model is hardcoded to `deepseek-v4-flash` at
|
||||
`https://api.deepseek.com`.
|
||||
|
||||
```sh
|
||||
export DEEPSEEK_API_KEY=...
|
||||
# Seed below the trigger: no checkpoint installed, no summary started
|
||||
go run ./benchmarks/context-maintenance-e2e -offline seed
|
||||
|
||||
# Seed both arms (pruned + control) with a large session and warm the cache
|
||||
go run ./benchmarks/context-maintenance-e2e seed
|
||||
|
||||
# Wait past the provider's cache TTL, then resume: prune the "pruned" arm and
|
||||
# compare cold-restart miss tokens
|
||||
# Reload: the projection version must not move and no summary may run
|
||||
go run ./benchmarks/context-maintenance-e2e resume
|
||||
|
||||
# Run the comprehension trials (agent must re-read a pruned file and answer
|
||||
# from it); exits non-zero unless every trial passes
|
||||
go run ./benchmarks/context-maintenance-e2e comprehension
|
||||
# Grow past the trigger: one summary, version +1, then no further summary
|
||||
export DEEPSEEK_API_KEY=...
|
||||
go run ./benchmarks/context-maintenance-e2e continue
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `-dir` | `benchmarks/context-maintenance-e2e/run` | State directory for `seed`/`resume` (sessions + `meta.json`, `resume-<ts>.json`). |
|
||||
| `-trials` | `5` | Number of comprehension trials. |
|
||||
| `-dir` | `benchmarks/context-maintenance-e2e/run` | State directory shared by the three legs (session + `meta.json`). |
|
||||
| `-max-usd` | `0.50` | Hard cost cap for the live legs. |
|
||||
| `-offline` | off | Skip the live provider; `seed` only. |
|
||||
|
||||
## See also
|
||||
|
||||
@@ -593,6 +593,24 @@ e2ebench -suite benchmarks/memorybench -budget 0 -policy memory-off -trajectorie
|
||||
e2ebench -mode compare on.json off.json # Memory utility section
|
||||
```
|
||||
|
||||
MemoryBench requires an OS read sandbox. It runs on macOS and Linux and
|
||||
refuses to start on Windows, where the benchmark cannot isolate the committed
|
||||
`tasks/<id>/memory/` answers from a graded agent. Each run also audits every
|
||||
recorded trajectory leg for a successful read of those answers; an answer read
|
||||
invalidates the result and makes the suite command exit nonzero. A missing or
|
||||
unreadable trajectory marks only that run incomplete; other tasks still run.
|
||||
Trajectories are captured temporarily when `-trajectories` is omitted.
|
||||
|
||||
The audit treats a completed `read_file` call naming a task-memory path as a
|
||||
read. For `bash` and `grep`, a task-memory path in the arguments plus an authored
|
||||
fact line in the result is **evidence** of a read, not a proof of how that text
|
||||
was obtained. `glob` and directory listings expose names but not fact bodies;
|
||||
the audit does not count them as answer reads. Commands that reach the answer
|
||||
through an indirect path, such as `cd` followed by a relative filename, may
|
||||
not be attributed by the trajectory check. The OS read sandbox is the primary
|
||||
boundary; this audit is a second check, not a proof that an unflagged run never
|
||||
accessed an answer.
|
||||
|
||||
Utility delta = paired Pass(on) − Pass(off). Harmful attribution is paired,
|
||||
never judged: the same task passed without memory and failed with it while
|
||||
recall fired. Scenario classes: exact, paraphrase, cjk, symbol, distractor
|
||||
@@ -606,8 +624,8 @@ update (revised value wins), pinned (prefix channel end to end).
|
||||
`benchmarks/compaction/` drives the real agent compaction path over a session
|
||||
that grows one generation at a time. Each generation appends a round of work
|
||||
and then folds, so generation N folds everything generations 1..N produced —
|
||||
which is the growth that matters, because a fold re-derives its digest from the
|
||||
whole canonical transcript rather than from the previous digest.
|
||||
which is the growth that matters, because a fold reads the model-visible
|
||||
projection, so the previous digest feeds the next one.
|
||||
|
||||
```bash
|
||||
go run ./benchmarks/compaction -mode=cost # offline, no API key
|
||||
@@ -634,16 +652,3 @@ probe, not a compaction loss.
|
||||
Probe answers are scored on whole words, and a wanted answer does not count if a
|
||||
rejected one appears anywhere in the same reply — "yes, but it has not been
|
||||
re-run since" is the shape a drifting digest produces, and it is not a pass.
|
||||
|
||||
### Fold arms
|
||||
|
||||
`-arm=full` (default) re-derives every digest from the canonical transcript, so
|
||||
digests never chain. `-arm=incremental` folds the model-visible view instead,
|
||||
feeding the previous digest back through the summarizer. The arms exist to price
|
||||
that trade: run the cost arm for what chaining saves, and the fidelity arm for
|
||||
what it costs.
|
||||
|
||||
```bash
|
||||
go run ./benchmarks/compaction -mode=cost -arm=incremental
|
||||
DEEPSEEK_API_KEY=… go run ./benchmarks/compaction -mode=fidelity -arm=incremental
|
||||
```
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/tokencount"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
@@ -42,7 +41,6 @@ func main() {
|
||||
report := flag.String("report", "1,2,4,8", "generations to report on")
|
||||
window := flag.Int("window", 128_000, "context window in tokens")
|
||||
control := flag.Bool("control", true, "fidelity: also score probes against full history")
|
||||
arm := flag.String("arm", "full", "full | incremental: re-derive each digest from canonical, or fold the previous projection")
|
||||
_ = flag.Bool("snip", false, "accepted and ignored: automatic snip projections are gone")
|
||||
out := flag.String("out", "", "write the JSON report here")
|
||||
flag.Parse()
|
||||
@@ -51,14 +49,11 @@ func main() {
|
||||
res []genResult
|
||||
err error
|
||||
)
|
||||
a := arms{incremental: *arm == "incremental"}
|
||||
switch {
|
||||
case *arm != "full" && *arm != "incremental":
|
||||
err = fmt.Errorf("unknown arm %q", *arm)
|
||||
case *mode == "cost":
|
||||
res, err = runCost(*gens, *window, a)
|
||||
case *mode == "fidelity":
|
||||
res, err = runFidelity(*gens, *window, *control, a)
|
||||
switch *mode {
|
||||
case "cost":
|
||||
res, err = runCost(*gens, *window)
|
||||
case "fidelity":
|
||||
res, err = runFidelity(*gens, *window, *control)
|
||||
default:
|
||||
err = fmt.Errorf("unknown mode %q", *mode)
|
||||
}
|
||||
@@ -66,9 +61,9 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printReport(*mode+" / "+*arm, res, reportAt(*report))
|
||||
printReport(*mode, res, reportAt(*report))
|
||||
if *out != "" {
|
||||
b, _ := json.MarshalIndent(map[string]any{"mode": *mode, "arm": *arm, "window": *window, "generations": res}, "", " ")
|
||||
b, _ := json.MarshalIndent(map[string]any{"mode": *mode, "window": *window, "generations": res}, "", " ")
|
||||
if werr := os.WriteFile(*out, append(b, '\n'), 0o644); werr != nil {
|
||||
fmt.Fprintln(os.Stderr, werr)
|
||||
os.Exit(1)
|
||||
@@ -101,7 +96,7 @@ type harness struct {
|
||||
calls *callRecorder
|
||||
}
|
||||
|
||||
func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorder, arm arms) *harness {
|
||||
func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorder) *harness {
|
||||
sess := newSession()
|
||||
path := filepath.Join(t.dir, "session.jsonl")
|
||||
a := agent.New(p, tool.NewRegistry(), sess, agent.Options{
|
||||
@@ -112,26 +107,10 @@ func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorde
|
||||
// boot's default when cfg.Agent.Keep is unset; without it the bench
|
||||
// would measure a configuration no real session runs.
|
||||
KeepPolicy: agent.KeepErrors,
|
||||
Ablation: foldArm(arm.incremental),
|
||||
}, rec.sink())
|
||||
return &harness{sess: sess, agentA: a, path: path, calls: rec}
|
||||
}
|
||||
|
||||
// foldArm switches full re-derivation off, which is what makes a fold read the
|
||||
// previous projection instead of the canonical transcript.
|
||||
// arms selects the maintenance behaviour under test. Snipping is off by default
|
||||
// so a run stays comparable with baselines recorded before it existed.
|
||||
type arms struct {
|
||||
incremental bool
|
||||
}
|
||||
|
||||
func foldArm(incremental bool) ablation.Set {
|
||||
if incremental {
|
||||
return ablation.New(ablation.FullFold)
|
||||
}
|
||||
return ablation.Set{}
|
||||
}
|
||||
|
||||
// runGeneration grows the session and folds it, returning what that fold cost.
|
||||
func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) genResult {
|
||||
growSession(h.sess, gen, probes)
|
||||
@@ -160,7 +139,7 @@ func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) ge
|
||||
return r
|
||||
}
|
||||
|
||||
func runCost(gens, window int, a arms) ([]genResult, error) {
|
||||
func runCost(gens, window int) ([]genResult, error) {
|
||||
dir, cleanup, err := tempDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -169,7 +148,7 @@ func runCost(gens, window int, a arms) ([]genResult, error) {
|
||||
|
||||
rec := &callRecorder{}
|
||||
p := &scriptedProvider{rec: rec, reply: syntheticDigest, window: window}
|
||||
h := newHarness(dir, p, window, rec, a)
|
||||
h := newHarness(dir, p, window, rec)
|
||||
|
||||
var out []genResult
|
||||
for gen := range gens {
|
||||
@@ -178,7 +157,7 @@ func runCost(gens, window int, a arms) ([]genResult, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func runFidelity(gens, window int, control bool, a arms) ([]genResult, error) {
|
||||
func runFidelity(gens, window int, control bool) ([]genResult, error) {
|
||||
key := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("fidelity mode needs DEEPSEEK_API_KEY")
|
||||
@@ -194,7 +173,7 @@ func runFidelity(gens, window int, control bool, a arms) ([]genResult, error) {
|
||||
defer cleanup()
|
||||
|
||||
rec := &callRecorder{}
|
||||
h := newHarness(dir, &recordingProvider{inner: p, rec: rec}, window, rec, a)
|
||||
h := newHarness(dir, &recordingProvider{inner: p, rec: rec}, window, rec)
|
||||
probes := probeSuite()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestRepeatedFoldsStayBoundedAndKeepSucceeding(t *testing.T) {
|
||||
reserve = 8192 // summaryOutputReserve: the digest the call must still return
|
||||
maxCalls = 7 // maxSummarySpans + the merge pass
|
||||
)
|
||||
res, err := runCost(gens, window, arms{})
|
||||
res, err := runCost(gens, window)
|
||||
if err != nil {
|
||||
t.Fatalf("runCost: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Cost-capped seed → resume → continue smoke for content-driven maintenance.
|
||||
// Offline covers seed/resume; live continue needs DEEPSEEK_API_KEY and allows
|
||||
// at most one summary after growing past compact_ratio=0.85.
|
||||
// Seed and resume run without a provider; live continue needs DEEPSEEK_API_KEY
|
||||
// and starts exactly one summary after growing past compact_ratio=0.85.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -70,7 +70,7 @@ func (s *recordSink) Emit(e event.Event) {
|
||||
func prov() (provider.Provider, *provider.Pricing) {
|
||||
key := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if key == "" {
|
||||
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set (use -offline for seed/resume)")
|
||||
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set (use -offline for seed)")
|
||||
os.Exit(1)
|
||||
}
|
||||
p, err := provider.New("openai", provider.Config{
|
||||
@@ -193,7 +193,10 @@ func seed(dir string, offline bool, cap *costCap) {
|
||||
m.ProjectedTokens, m.TriggerTokens, m.ProjectionVersion, m.SummaryCalls, m.SpentUSD)
|
||||
}
|
||||
|
||||
func resume(dir string, offline bool) {
|
||||
// resume reads the seeded session back and checks that loading it neither
|
||||
// advances the projection version nor pays for a summary. It never samples, so
|
||||
// it takes no provider and needs no API key.
|
||||
func resume(dir string) {
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
m := readMeta(dir)
|
||||
sess, err := sessionstore.LoadSession(path)
|
||||
@@ -202,11 +205,7 @@ func resume(dir string, offline bool) {
|
||||
os.Exit(1)
|
||||
}
|
||||
sink := &recordSink{}
|
||||
var p provider.Provider
|
||||
if !offline {
|
||||
p, _ = prov()
|
||||
}
|
||||
a := newAgent(p, sess, path, sink)
|
||||
a := newAgent(nil, sess, path, sink)
|
||||
snap := a.ContextMaintenanceSnapshot()
|
||||
if snap.ProjectionVersion != m.ProjectionVersion {
|
||||
fmt.Fprintf(os.Stderr, "resume version = %d, want %d\n", snap.ProjectionVersion, m.ProjectionVersion)
|
||||
@@ -258,12 +257,14 @@ func cont(dir string, offline bool, cap *costCap) {
|
||||
fmt.Fprintln(os.Stderr, "continue prepare:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if sink.summaryStarts > 1 {
|
||||
fmt.Fprintf(os.Stderr, "continue started %d summaries, want ≤1\n", sink.summaryStarts)
|
||||
// The fixture is already above the trigger it was grown past, so a leg that
|
||||
// starts no summary has not observed the fold it exists to test.
|
||||
if sink.summaryStarts != 1 {
|
||||
fmt.Fprintf(os.Stderr, "continue started %d summaries, want exactly 1\n", sink.summaryStarts)
|
||||
os.Exit(1)
|
||||
}
|
||||
after := a.ContextMaintenanceSnapshot()
|
||||
if sink.summaryStarts == 1 && after.ProjectionVersion != m.ProjectionVersion+1 {
|
||||
if after.ProjectionVersion != m.ProjectionVersion+1 {
|
||||
fmt.Fprintf(os.Stderr, "continue version = %d, want %d after one summary\n",
|
||||
after.ProjectionVersion, m.ProjectionVersion+1)
|
||||
os.Exit(1)
|
||||
@@ -306,14 +307,14 @@ func cont(dir string, offline bool, cap *costCap) {
|
||||
func main() {
|
||||
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory")
|
||||
maxUSD := flag.Float64("max-usd", defaultMaxUSD, "hard cost cap for live API legs")
|
||||
offline := flag.Bool("offline", false, "skip live provider (seed/resume only)")
|
||||
offline := flag.Bool("offline", false, "skip live provider (seed only)")
|
||||
flag.Parse()
|
||||
cap := &costCap{maxUSD: *maxUSD}
|
||||
switch flag.Arg(0) {
|
||||
case "seed":
|
||||
seed(*dir, *offline, cap)
|
||||
case "resume":
|
||||
resume(*dir, *offline)
|
||||
resume(*dir)
|
||||
case "continue":
|
||||
cont(*dir, *offline, cap)
|
||||
default:
|
||||
|
||||
@@ -77,11 +77,21 @@ marks the run contaminated and takes it out of every statistic.
|
||||
## Index corpus v2, if this is reopened
|
||||
|
||||
The present index tasks are not a fit substrate: two of six carry stopping
|
||||
behaviour that dominates the signal. A task should qualify only if a calibration
|
||||
run shows `PostSufficientSearches == 0`, alongside the existing requirements
|
||||
(successful tool call, answer in the output rather than the cue, rank 1,
|
||||
`FirstHitCoverage` complete, one or two simple values, nothing in the
|
||||
workspace). Keep i03 and i04 — as a stopping corpus, not an efficiency one.
|
||||
behaviour that dominates the signal. A task should qualify only if a run-index
|
||||
run reports a `stopping_class` other than `PostSufficientRetrieval` or
|
||||
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
|
||||
all qualify, and a defensive read is deliberately not counted as waste. The
|
||||
class comes from the three counts in that report — `searches_after_sufficient`,
|
||||
`reads_after_sufficient` and `escapes_after_sufficient` — together with how
|
||||
sufficiency was reached and how many rounds followed it. The remaining
|
||||
requirements still apply — successful tool call, answer in the output rather
|
||||
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
|
||||
nothing in the workspace. Keep i03 and i04 — as a stopping corpus, not an
|
||||
efficiency one.
|
||||
|
||||
`-mode=calibrate` is not a screening tool: it builds and reads back the
|
||||
fixtures, and reads nothing else, so its cue-visibility profile is the same for
|
||||
every task.
|
||||
|
||||
Then repeats rather than levels: cue-present against cue-absent on each task's
|
||||
own boundary, three runs per cell. What is unknown is the variance, and four
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
grep -q "pnpm install" answer.txt && ! grep -q "npm install" answer.txt
|
||||
grep -q "pnpm install" answer.txt && ! grep -qE '(^|[^p])npm install' answer.txt
|
||||
@@ -0,0 +1,13 @@
|
||||
import normalize
|
||||
|
||||
def merge_files(paths):
|
||||
rows = []
|
||||
header = None
|
||||
for p in paths:
|
||||
lines = [l.rstrip("\n") for l in open(p)]
|
||||
if not lines:
|
||||
continue
|
||||
if header is None:
|
||||
header = normalize.header(lines[0])
|
||||
rows.extend(normalize.row(l) for l in lines[1:])
|
||||
return [header] + rows
|
||||
@@ -0,0 +1,5 @@
|
||||
def header(line):
|
||||
return ",".join(part.strip().lower() for part in line.split(","))
|
||||
|
||||
def row(line):
|
||||
return ",".join(part.strip() for part in line.split(","))
|
||||
@@ -0,0 +1,5 @@
|
||||
import datetime
|
||||
|
||||
def parse(s):
|
||||
y, m, d = s.split("-")
|
||||
return datetime.date(int(y), int(m), int(d))
|
||||
@@ -0,0 +1,5 @@
|
||||
import parse_date
|
||||
|
||||
def in_window(day, start, end):
|
||||
d = parse_date.parse(day)
|
||||
return parse_date.parse(start) <= d <= parse_date.parse(end)
|
||||
@@ -0,0 +1,11 @@
|
||||
import tokenizer
|
||||
|
||||
def build(docs):
|
||||
idx = {}
|
||||
for doc_id, text in docs.items():
|
||||
for tok in tokenizer.tokens(text):
|
||||
idx.setdefault(tok, []).append(doc_id)
|
||||
return idx
|
||||
|
||||
def lookup(idx, word):
|
||||
return sorted(idx.get(word.lower(), []))
|
||||
@@ -0,0 +1,5 @@
|
||||
import re
|
||||
|
||||
|
||||
def tokens(text):
|
||||
return re.findall(r"[a-z0-9]+", text.lower())
|
||||
@@ -0,0 +1,11 @@
|
||||
import parse
|
||||
|
||||
def balance(lines):
|
||||
total = 0.0
|
||||
for line in lines:
|
||||
kind, amount = parse.parse_line(line)
|
||||
if kind == "deposit":
|
||||
total += amount
|
||||
elif kind == "withdraw":
|
||||
total -= amount
|
||||
return total
|
||||
@@ -0,0 +1,3 @@
|
||||
def parse_line(line):
|
||||
kind, amount = line.strip().split()
|
||||
return kind, float(amount.replace(",", ""))
|
||||
@@ -0,0 +1,4 @@
|
||||
import paginate
|
||||
|
||||
def next_link(items, size, page):
|
||||
return f"?page={page + 1}" if page < len(paginate.pages(items, size)) else None
|
||||
@@ -0,0 +1,2 @@
|
||||
def pages(items, size):
|
||||
return [items[i:i + size] for i in range(0, len(items), size)]
|
||||
@@ -0,0 +1,5 @@
|
||||
def apply_pct(price, pct):
|
||||
return price * (1 - pct / 100.0)
|
||||
|
||||
def apply_coupon(price, coupon):
|
||||
return price - coupon
|
||||
@@ -0,0 +1,7 @@
|
||||
import discounts
|
||||
|
||||
|
||||
def final_price(base, pct, coupon):
|
||||
price = discounts.apply_pct(base, pct)
|
||||
price = discounts.apply_coupon(price, coupon)
|
||||
return int(max(price, 0.0) * 100) / 100
|
||||
@@ -0,0 +1,6 @@
|
||||
import tiers
|
||||
|
||||
|
||||
def overage_units(used, plan):
|
||||
included = tiers.included(plan)
|
||||
return max(0, used - included)
|
||||
@@ -0,0 +1,9 @@
|
||||
PLANS = {"basic": 100, "pro": 1000}
|
||||
|
||||
def included(plan):
|
||||
return PLANS[plan]
|
||||
|
||||
def tier_for(units):
|
||||
if units > 100:
|
||||
return "pro"
|
||||
return "basic"
|
||||
@@ -0,0 +1,3 @@
|
||||
def delay_ms(base, attempt, cap):
|
||||
delay = base * (2 ** (attempt - 1))
|
||||
return min(delay, cap)
|
||||
@@ -0,0 +1,11 @@
|
||||
import re
|
||||
|
||||
|
||||
def slugify(title):
|
||||
out = []
|
||||
for ch in title.lower():
|
||||
if ch.isalnum():
|
||||
out.append(ch)
|
||||
elif ch in " -_":
|
||||
out.append("-")
|
||||
return re.sub(r"-+", "-", "".join(out)).strip("-")
|
||||
@@ -0,0 +1,6 @@
|
||||
import slug
|
||||
|
||||
BASE = "/articles/"
|
||||
|
||||
def article_url(title):
|
||||
return BASE + slug.slugify(title)
|
||||
@@ -0,0 +1,2 @@
|
||||
def c_to_f(c):
|
||||
return c * 9 / 5 + 32
|
||||
@@ -0,0 +1,9 @@
|
||||
import convert
|
||||
|
||||
def band(c):
|
||||
f = convert.c_to_f(c)
|
||||
if f >= 90:
|
||||
return "hot"
|
||||
if f >= 60:
|
||||
return "warm"
|
||||
return "cold"
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/safety/sandbox"
|
||||
)
|
||||
|
||||
// MemoryBench keeps its seeded facts in the suite checkout. The graded child
|
||||
// receives only workdir/, but without a read boundary it can still search the
|
||||
// host for that checkout or read the same facts from Git objects.
|
||||
func validateAnswerIsolation(tasks []task) error {
|
||||
for _, t := range tasks {
|
||||
if t.answerRoot != "" && fileExists(filepath.Join(t.dir, "workdir", "reasonix.toml")) {
|
||||
return fmt.Errorf("%s already has a project config; answer isolation cannot replace it", t.dir)
|
||||
}
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if t.answerRoot == "" {
|
||||
continue
|
||||
}
|
||||
if !sandbox.Available() {
|
||||
return fmt.Errorf("%s contains answer material, but this host has no OS read sandbox", t.answerRoot)
|
||||
}
|
||||
roots, err := answerReadRoots(t.answerRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stateBase, err := benchStateBase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, root := range roots {
|
||||
for _, path := range []string{os.TempDir(), stateBase} {
|
||||
if within(root, resolvedPath(path)) {
|
||||
return fmt.Errorf("benchmark runtime path %s lies inside forbidden answer root %s", path, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stageAnswerIsolation(answerRoot, work string) error {
|
||||
if answerRoot == "" {
|
||||
return nil
|
||||
}
|
||||
roots, err := answerReadRoots(answerRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, root := range roots {
|
||||
if within(root, resolvedPath(work)) {
|
||||
return fmt.Errorf("workdir %s lies inside forbidden answer root %s", work, root)
|
||||
}
|
||||
}
|
||||
paths, err := json.Marshal(roots) // JSON strings are valid TOML basic strings.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(work, "reasonix.toml")
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create isolated project config: %w", err)
|
||||
}
|
||||
_, writeErr := fmt.Fprintf(f, "[sandbox]\nbash = \"enforce\"\nforbid_read = %s\n", paths)
|
||||
closeErr := f.Close()
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// The temporary policy is harness state, not a task artifact. Remove it before
|
||||
// grading and mutation timing so it cannot affect either measurement.
|
||||
func removeAnswerIsolation(answerRoot, work string) error {
|
||||
if answerRoot == "" {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(filepath.Join(work, "reasonix.toml"))
|
||||
}
|
||||
|
||||
func answerReadRoots(answerRoot string) ([]string, error) {
|
||||
tasks, err := filepath.Abs(answerRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = resolvedPath(tasks)
|
||||
roots := []string{tasks}
|
||||
// A worktree's .git is a pointer into the common object store. Hide that
|
||||
// store and every registered checkout: the same answer files may be present
|
||||
// in the primary checkout as well as in the one running this harness.
|
||||
cmd := exec.Command("git", "-C", tasks, "worktree", "list", "--porcelain", "-z")
|
||||
listing, err := cmd.Output()
|
||||
if err != nil {
|
||||
if gitMarkerAbove(tasks) {
|
||||
return nil, fmt.Errorf("list checkout worktrees for answer isolation: %w", err)
|
||||
}
|
||||
return roots, nil // Source archive without a Git object store.
|
||||
}
|
||||
for line := range strings.SplitSeq(string(listing), "\x00") {
|
||||
if path, ok := strings.CutPrefix(line, "worktree "); ok {
|
||||
roots = append(roots, resolvedPath(path))
|
||||
}
|
||||
}
|
||||
common, err := exec.Command("git", "-C", tasks, "rev-parse", "--git-common-dir").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locate Git object store for answer isolation: %w", err)
|
||||
}
|
||||
gitDir := strings.TrimSuffix(string(common), "\n")
|
||||
if !filepath.IsAbs(gitDir) {
|
||||
gitDir = filepath.Join(tasks, gitDir)
|
||||
}
|
||||
roots = append(roots, resolvedPath(gitDir))
|
||||
return uniqueAnswerRoots(roots), nil
|
||||
}
|
||||
|
||||
func gitMarkerAbove(path string) bool {
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
parent := filepath.Dir(path)
|
||||
if parent == path {
|
||||
return false
|
||||
}
|
||||
path = parent
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueAnswerRoots(paths []string) []string {
|
||||
seen := make(map[string]bool, len(paths))
|
||||
var out []string
|
||||
for _, path := range paths {
|
||||
if path != "" && !seen[path] {
|
||||
seen[path] = true
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
)
|
||||
|
||||
func TestMemoryBenchRequiresReadSandbox(t *testing.T) {
|
||||
tasks, err := loadTasks("../../benchmarks/memorybench")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("MemoryBench corpus is empty")
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.answerRoot == "" {
|
||||
t.Fatalf("%s can run without the answer boundary", task.ID)
|
||||
}
|
||||
}
|
||||
err = validateAnswerIsolation(tasks)
|
||||
if sandbox.Available() && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sandbox.Available() && err == nil {
|
||||
t.Fatal("MemoryBench accepted a host with no OS read sandbox")
|
||||
}
|
||||
if sandbox.Available() {
|
||||
previous := benchStateBase
|
||||
benchStateBase = func() (string, error) { return filepath.Join(tasks[0].answerRoot, "cache"), nil }
|
||||
t.Cleanup(func() { benchStateBase = previous })
|
||||
if err := validateAnswerIsolation(tasks); err == nil {
|
||||
t.Fatal("MemoryBench accepted a state root inside the forbidden checkout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryBenchAnswerIsolationAtSandboxBoundary(t *testing.T) {
|
||||
if !sandbox.Available() {
|
||||
t.Skip("this host has no OS read sandbox")
|
||||
}
|
||||
const suite = "../../benchmarks/memorybench"
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) == 0 || tasks[0].answerRoot == "" {
|
||||
t.Fatal("MemoryBench answer material was not identified")
|
||||
}
|
||||
if err := validateAnswerIsolation(tasks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
if err := stageAnswerIsolation(tasks[0].answerRoot, work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nbash = \"off\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
cfg, err := config.LoadForRoot(work)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.BashMode() != "enforce" {
|
||||
t.Fatalf("sandbox mode = %q, want enforce even when the user config disables it", cfg.BashMode())
|
||||
}
|
||||
visible := filepath.Join(work, "visible.txt")
|
||||
if err := os.WriteFile(visible, []byte("visible-control"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer := filepath.Join(suite, "tasks", "mb-contradiction", "memory", "project", "package-manager.md")
|
||||
answer, err = filepath.Abs(answer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: []string{work}, ForbidReadRoots: cfg.ForbidReadRootsForRoot(work)}
|
||||
read := func(path string) (string, error) {
|
||||
t.Helper()
|
||||
argv, wrapped := sandbox.CommandArgs(spec, []string{"cat", path})
|
||||
if !wrapped {
|
||||
t.Fatal("OS sandbox did not wrap the command")
|
||||
}
|
||||
out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
if got, err := read(visible); err != nil || !strings.Contains(got, "visible-control") {
|
||||
t.Fatalf("control file read = %q, %v", got, err)
|
||||
}
|
||||
if got, err := read(answer); err == nil || strings.Contains(got, "npm install") {
|
||||
t.Fatalf("answer file remained readable through the sandbox: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
|
||||
const answerRoot = "../../benchmarks/memorybench/tasks"
|
||||
if err := stageAnswerIsolation(answerRoot, filepath.Dir(answerRoot)); err == nil {
|
||||
t.Fatal("workdir inside the checkout was accepted")
|
||||
}
|
||||
work := t.TempDir()
|
||||
path := filepath.Join(work, "reasonix.toml")
|
||||
if err := os.WriteFile(path, []byte("# task fixture\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stageAnswerIsolation(answerRoot, work); err == nil {
|
||||
t.Fatal("existing task config was overwritten")
|
||||
}
|
||||
if data, _ := os.ReadFile(path); string(data) != "# task fixture\n" {
|
||||
t.Fatalf("task config changed: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
suite := t.TempDir()
|
||||
taskDir := filepath.Join(suite, "tasks", "demo")
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "memory", "project"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verify := "#!/usr/bin/env bash\nset -e\ntest ! -e reasonix.toml\ngrep -q '^ok$' answer.txt\n"
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
test -f reasonix.toml
|
||||
printf 'ok\n' > answer.txt
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--metrics) printf '{"complete":true}\n' > "$2"; shift 2 ;;
|
||||
--trajectory) printf '{"event":{"kind":"text","text":"done"}}\n' > "$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tk := task{ID: "demo", Prompt: "write the answer", TimeoutSec: 30, dir: taskDir, answerRoot: filepath.Join(suite, "tasks")}
|
||||
r := runTask(suiteConfig{bin: bin}, tk)
|
||||
if !r.Passed {
|
||||
t.Fatalf("isolated task failed grading: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
suite := t.TempDir()
|
||||
taskDir := filepath.Join(suite, "tasks", "demo")
|
||||
answer := filepath.Join(taskDir, "memory", "project", "fact.md")
|
||||
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(answer, []byte("---\nname: fact\n---\nA unique answer that the graded child must not read.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/bin/sh\ntest -f answer.txt\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
printf 'ok\n' > answer.txt
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--metrics) printf '{"complete":true}\n' > "$2"; shift 2 ;;
|
||||
--trajectory)
|
||||
printf '{"event":{"kind":"tool_dispatch","tool":{"id":"read-1","name":"read_file","args":"{\\"path\\":\\"%s\\"}"}}}\n' "$ANSWER_FILE" > "$2"
|
||||
printf '{"event":{"kind":"tool_result","tool":{"id":"read-1","name":"read_file","output":"A unique answer that the graded child must not read."}}}\n' >> "$2"
|
||||
shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ANSWER_FILE", answer)
|
||||
tk := task{ID: "demo", Prompt: "write the answer", TimeoutSec: 30, dir: taskDir, answerRoot: filepath.Join(suite, "tasks")}
|
||||
r := runTask(suiteConfig{bin: bin}, tk)
|
||||
if r.Passed || !errors.Is(r.answerAuditErr, errAnswerRead) {
|
||||
t.Fatalf("answer-read trajectory was accepted: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
root := filepath.Join(t.TempDir(), "tasks")
|
||||
var tasks []task
|
||||
for _, id := range []string{"missing", "complete"} {
|
||||
dir := filepath.Join(root, id)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "verify.sh"), []byte("#!/bin/sh\ntest -f answer.txt\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks = append(tasks, task{ID: id, Prompt: id, TimeoutSec: 30, dir: dir, answerRoot: root})
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
printf 'ok\n' > answer.txt
|
||||
traj=''
|
||||
prompt=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--metrics) printf '{"complete":true}\n' > "$2"; shift 2 ;;
|
||||
--trajectory) traj="$2"; shift 2 ;;
|
||||
*) prompt="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
if [ "$prompt" = complete ]; then
|
||||
printf '{"event":{"kind":"text","text":"done"}}\n' > "$traj"
|
||||
fi
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := runSuite(suiteConfig{bin: bin}, tasks)
|
||||
if len(results) != 2 || !errors.Is(results[0].answerAuditErr, errAnswerAuditIncomplete) || !results[1].Passed {
|
||||
t.Fatalf("missing trajectory stopped the suite: %+v", results)
|
||||
}
|
||||
results[0].Outcome = "renamed display status"
|
||||
if !answerAuditFailed(results[0]) || answerLeakDetected(results[0]) {
|
||||
t.Fatal("audit control flow depended on the display outcome")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testing.T) {
|
||||
const fact = "A unique answer that the graded child must not read."
|
||||
const dispatch = `{"event":{"kind":"tool_dispatch","tool":{"id":"a","name":"bash","args":"{\"command\":\"cat /repo/tasks/demo/memory/project/fact.md\"}"}}}`
|
||||
for _, tc := range []struct {
|
||||
name, data string
|
||||
want bool
|
||||
}{
|
||||
{"successful shell read", dispatch + "\n" + `{"event":{"kind":"tool_result","tool":{"id":"a","name":"bash","output":"` + fact + `","execution":{"exitCode":0}}}}`, true},
|
||||
{"sandbox denial", dispatch + "\n" + `{"event":{"kind":"tool_result","tool":{"id":"a","name":"bash","output":"` + fact + `","execution":{"exitCode":1}}}}`, false},
|
||||
{"directory listing", `{"event":{"kind":"tool_dispatch","tool":{"id":"a","name":"bash","args":"{\"command\":\"ls /repo/tasks/demo/memory\"}"}}}` + "\n" + `{"event":{"kind":"tool_result","tool":{"id":"a","name":"bash","output":"fact.md"}}}`, false},
|
||||
{"native read denial", `{"event":{"kind":"tool_result","tool":{"name":"read_file","args":"{\"path\":\"/repo/tasks/demo/memory/project/fact.md\"}","err":"sandbox denied"}}}`, false},
|
||||
{"grep reads fact", `{"event":{"kind":"tool_result","tool":{"name":"grep","args":"{\"path\":\"/repo/tasks/demo/memory/project/fact.md\",\"pattern\":\"unique\"}","output":"` + fact + `"}}}`, true},
|
||||
{"glob lists name only", `{"event":{"kind":"tool_result","tool":{"name":"glob","args":"{\"pattern\":\"/repo/tasks/demo/memory/project/*.md\"}","output":"/repo/tasks/demo/memory/project/fact.md"}}}`, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "run.trajectory.jsonl")
|
||||
if err := os.WriteFile(path, []byte(tc.data+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := scanAnswerRead(path, []string{fact})
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("scanAnswerRead = %t, %v; want %t", got, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "tasks")
|
||||
answer := filepath.Join(root, "demo", "memory", "project", "fact.md")
|
||||
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(answer, []byte("---\nname: fact\n---\nA fact body in the first trajectory leg.\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
for index, data := range []string{
|
||||
`{"event":{"kind":"tool_result","tool":{"name":"bash","args":"{\"command\":\"cat /repo/tasks/demo/memory/project/fact.md\"}","output":"A fact body in the first trajectory leg."}}}`,
|
||||
`{"event":{"kind":"text","text":"done"}}`,
|
||||
} {
|
||||
path := segmentTrajectoryPath(dir, "demo", segment{index: index + 1}, 2)
|
||||
if err := os.WriteFile(path, []byte(data+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
read, err := auditAnswerTrajectories(dir, task{ID: "demo", answerRoot: root}, 2)
|
||||
if err != nil || !read {
|
||||
t.Fatalf("earlier answer read was missed: read=%t err=%v", read, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var answerMemoryPath = regexp.MustCompile(`(?:^|[/\\])tasks[/\\][^/\\]+[/\\]memory[/\\]`)
|
||||
|
||||
var (
|
||||
errAnswerRead = errors.New("graded trajectory read task memory")
|
||||
errAnswerAuditIncomplete = errors.New("answer trajectory audit incomplete")
|
||||
)
|
||||
|
||||
type answerToolRecord struct {
|
||||
Event *struct {
|
||||
Kind string `json:"kind"`
|
||||
Tool *struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
Output string `json:"output"`
|
||||
Err string `json:"err"`
|
||||
Execution *struct {
|
||||
State string `json:"state"`
|
||||
ExitCode *int `json:"exitCode"`
|
||||
} `json:"execution"`
|
||||
} `json:"tool"`
|
||||
} `json:"event"`
|
||||
}
|
||||
|
||||
type answerToolCall struct{ name, args string }
|
||||
|
||||
// answerBodyLines are the authored facts, excluding repeated frontmatter.
|
||||
// A path listing alone is not evidence that the child read a fact body.
|
||||
func answerBodyLines(root string) ([]string, error) {
|
||||
var lines []string
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || filepath.Ext(path) != ".md" || !strings.Contains(path, string(filepath.Separator)+"memory"+string(filepath.Separator)) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := string(data)
|
||||
if strings.HasPrefix(body, "---\n") {
|
||||
if end := strings.Index(body[4:], "\n---\n"); end >= 0 {
|
||||
body = body[4+end+5:]
|
||||
}
|
||||
}
|
||||
for line := range strings.SplitSeq(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) >= 20 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return lines, err
|
||||
}
|
||||
|
||||
// scanAnswerRead checks completed tool results, not dispatched attempts: the
|
||||
// OS sandbox may correctly deny a cat of a forbidden path. A successful
|
||||
// native file read is direct evidence; a shell read needs both the forbidden
|
||||
// path and an authored fact body in its output.
|
||||
func scanAnswerRead(path string, bodyLines []string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
calls := map[string]answerToolCall{}
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 1<<20), 64<<20)
|
||||
for sc.Scan() {
|
||||
var rec answerToolRecord
|
||||
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
|
||||
return false, fmt.Errorf("decode trajectory: %w", err)
|
||||
}
|
||||
if rec.Event == nil || rec.Event.Tool == nil {
|
||||
continue
|
||||
}
|
||||
tl := rec.Event.Tool
|
||||
if rec.Event.Kind == "tool_dispatch" {
|
||||
if tl.ID != "" && tl.Args != "" {
|
||||
calls[tl.ID] = answerToolCall{tl.Name, tl.Args}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rec.Event.Kind != "tool_result" {
|
||||
continue
|
||||
}
|
||||
call := answerToolCall{tl.Name, tl.Args}
|
||||
if prior, ok := calls[tl.ID]; ok {
|
||||
if call.name == "" {
|
||||
call.name = prior.name
|
||||
}
|
||||
if call.args == "" {
|
||||
call.args = prior.args
|
||||
}
|
||||
}
|
||||
delete(calls, tl.ID)
|
||||
if tl.Err != "" || tl.Execution != nil && (tl.Execution.State == "not_run" || tl.Execution.ExitCode != nil && *tl.Execution.ExitCode != 0) {
|
||||
continue
|
||||
}
|
||||
if !answerMemoryPath.MatchString(call.args) {
|
||||
continue
|
||||
}
|
||||
if call.name == "read_file" && tl.Output != "" {
|
||||
return true, nil
|
||||
}
|
||||
if call.name == "bash" || call.name == "grep" {
|
||||
for _, line := range bodyLines {
|
||||
if strings.Contains(tl.Output, line) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
|
||||
if segments < 1 {
|
||||
return false, fmt.Errorf("run recorded no segments")
|
||||
}
|
||||
lines, err := answerBodyLines(t.answerRoot)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for index := 1; index <= segments; index++ {
|
||||
path := segmentTrajectoryPath(dir, t.ID, segment{index: index}, segments)
|
||||
read, err := scanAnswerRead(path, lines)
|
||||
if err != nil || read {
|
||||
return read, err
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func answerTrajectoryDir(dir string, required bool) (string, func(), error) {
|
||||
cleanup := func() {}
|
||||
if required && dir == "" {
|
||||
var err error
|
||||
dir, err = os.MkdirTemp("", "e2ebench-answer-audit-")
|
||||
if err != nil {
|
||||
return "", cleanup, err
|
||||
}
|
||||
cleanup = func() { _ = os.RemoveAll(dir) }
|
||||
}
|
||||
if dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
}
|
||||
return dir, cleanup, nil
|
||||
}
|
||||
|
||||
func auditAnswerRun(r *result, dir string, t task) bool {
|
||||
if t.answerRoot == "" {
|
||||
return true
|
||||
}
|
||||
read, err := auditAnswerTrajectories(dir, t, r.Segments)
|
||||
if err != nil {
|
||||
markAnswerAuditIncomplete(r, err)
|
||||
r.Note = "answer audit: " + err.Error()
|
||||
return false
|
||||
}
|
||||
if read {
|
||||
r.Outcome = "answer_leak"
|
||||
r.answerAuditErr = errAnswerRead
|
||||
r.Note = "answer audit: graded trajectory read a task memory file"
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func markAnswerAuditIncomplete(r *result, err error) {
|
||||
r.Outcome = "answer_audit_incomplete"
|
||||
r.answerAuditErr = fmt.Errorf("%w: %w", errAnswerAuditIncomplete, err)
|
||||
}
|
||||
|
||||
func answerAuditFailed(r result) bool {
|
||||
return r.answerAuditErr != nil
|
||||
}
|
||||
|
||||
func answerLeakDetected(r result) bool {
|
||||
return errors.Is(r.answerAuditErr, errAnswerRead)
|
||||
}
|
||||
|
||||
func recordTaskTrajectory(r *result, path string, t task) {
|
||||
if summary, err := summarizeTrajectory(path); err == nil {
|
||||
r.Trajectory = summary
|
||||
}
|
||||
applyMemoryStats(r, path, t)
|
||||
}
|
||||
@@ -22,6 +22,12 @@ const verificationStressDir = "../../benchmarks/verification-stress"
|
||||
// must ship a solution/ proving its grader can be satisfied.
|
||||
const trainCorpusDir = "../../benchmarks/train"
|
||||
|
||||
// memorybenchDir holds the recall-behavior corpus. It ships no solution/
|
||||
// fixtures (see #11329), so it only gets the pristine-seed half of the
|
||||
// authoring guard below; the mb-contradiction fix has its own targeted
|
||||
// grader test instead.
|
||||
const memorybenchDir = "../../benchmarks/memorybench"
|
||||
|
||||
// protectedFiles reads the manifest embedded in a no-solution grader. The
|
||||
// manifest lives inside verify.sh precisely because e2ebench drops that file
|
||||
// in only after the run, so the agent never sees which files are watched.
|
||||
@@ -174,7 +180,7 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -348,3 +354,39 @@ func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mbContradictionDir is the fixture behind #11329: its grader required
|
||||
// "pnpm install" while also forbidding "npm install", a substring of the very
|
||||
// answer it demanded, so no answer.txt could ever satisfy both halves.
|
||||
const mbContradictionDir = "../../benchmarks/memorybench/tasks/mb-contradiction"
|
||||
|
||||
// TestContradictionGraderAcceptsPnpmRejectsNpm pins the four cases #11329
|
||||
// worked through by hand: the intended answer must pass, the stale answer it
|
||||
// is meant to reject must still fail, and disambiguating the negative check
|
||||
// must not let a genuine "npm install" line through when both are present.
|
||||
func TestContradictionGraderAcceptsPnpmRejectsNpm(t *testing.T) {
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash unavailable; the grader needs a POSIX shell")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want bool
|
||||
}{
|
||||
{"pnpm install", "pnpm install\n", true},
|
||||
{"npm install", "npm install\n", false},
|
||||
{"both lines", "pnpm install\nnpm install\n", false},
|
||||
{"pnpm install with flag", "pnpm install --frozen-lockfile\n", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
work := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(work, "answer.txt"), []byte(c.content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := grade(work, mbContradictionDir); got != c.want {
|
||||
t.Fatalf("answer.txt = %q: grade() = %v, want %v", c.content, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+58
-66
@@ -11,12 +11,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/config"
|
||||
@@ -48,6 +45,7 @@ type task struct {
|
||||
SeedCorrect string `toml:"seed_correct" json:"-"`
|
||||
SeedWrong string `toml:"seed_wrong" json:"-"`
|
||||
dir string
|
||||
answerRoot string
|
||||
}
|
||||
|
||||
type runMetrics struct {
|
||||
@@ -126,10 +124,11 @@ type result struct {
|
||||
Profile string `json:"profile"`
|
||||
// Arm is the ablation arm the harness requested, not the arm the child
|
||||
// reported, so a run that died before writing metrics is still attributable.
|
||||
Arm string `json:"arm"`
|
||||
Passed bool
|
||||
Skipped bool
|
||||
Note string
|
||||
Arm string `json:"arm"`
|
||||
Passed bool
|
||||
Skipped bool
|
||||
Note string
|
||||
answerAuditErr error
|
||||
// Memory shadow: recall decisions and point-of-use evidence extracted from
|
||||
// the trajectory (see memorybench.go). Zero for suites without seeds.
|
||||
MemoryRecallEvents int `json:"memory_recall_events,omitempty"`
|
||||
@@ -288,6 +287,7 @@ func main() {
|
||||
tapeRecord, tapeReplay := registerTapeFlags()
|
||||
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N (all passed) beside mean pass@1")
|
||||
flag.Parse()
|
||||
*trajDir = absTrajectoryDir(*trajDir)
|
||||
if *trials > 1 && *attempts > 1 {
|
||||
fmt.Fprintln(os.Stderr, "e2ebench: -trials and -attempts count repeats differently; use one")
|
||||
os.Exit(2)
|
||||
@@ -358,6 +358,10 @@ func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := validateAnswerIsolation(tasks); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "load suite:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
results := runSuite(cfg, tasks)
|
||||
|
||||
@@ -370,17 +374,22 @@ func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
|
||||
} else {
|
||||
fmt.Print(report)
|
||||
}
|
||||
if outJSON == "" {
|
||||
return
|
||||
if outJSON != "" {
|
||||
b, err := json.MarshalIndent(results, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "marshal json:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(outJSON, b, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write json:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
b, err := json.MarshalIndent(results, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "marshal json:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(outJSON, b, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write json:", err)
|
||||
os.Exit(1)
|
||||
for _, r := range results {
|
||||
if answerAuditFailed(r) {
|
||||
fmt.Fprintln(os.Stderr, "MemoryBench answer audit:", r.Note)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,37 +404,6 @@ func emit(report, outMD, _ string) {
|
||||
fmt.Print(report)
|
||||
}
|
||||
|
||||
func loadTasks(suite string) ([]task, error) {
|
||||
tasksDir := filepath.Join(suite, "tasks")
|
||||
entries, err := os.ReadDir(tasksDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tasks []task
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(tasksDir, e.Name())
|
||||
var t task
|
||||
data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
}
|
||||
if _, err := toml.Decode(string(data), &t); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
}
|
||||
t.ID = e.Name()
|
||||
t.dir = dir
|
||||
if t.TimeoutSec == 0 {
|
||||
t.TimeoutSec = 240
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func exitNoTasks(suite string) {
|
||||
dir := filepath.Join(suite, "tasks")
|
||||
if _, statErr := os.Stat(dir); statErr != nil {
|
||||
@@ -508,7 +486,11 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
|
||||
continue
|
||||
}
|
||||
if cfg.trials > 1 {
|
||||
results = append(results, runTrials(cfg, t, &total)...)
|
||||
trials := runTrials(cfg, t, &total)
|
||||
results = append(results, trials...)
|
||||
if len(trials) > 0 && answerLeakDetected(trials[len(trials)-1]) {
|
||||
return results
|
||||
}
|
||||
continue
|
||||
}
|
||||
var cumWallMs int64
|
||||
@@ -521,6 +503,9 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
|
||||
}
|
||||
total += r.PromptTokens + r.CompletionTokens
|
||||
results = append(results, r)
|
||||
if answerLeakDetected(r) {
|
||||
return results
|
||||
}
|
||||
if r.Passed || (cfg.budget > 0 && total >= cfg.budget) {
|
||||
break
|
||||
}
|
||||
@@ -548,12 +533,15 @@ func runTask(cfg suiteConfig, t task) result {
|
||||
// to exist before the first child starts, and the digest below reads the
|
||||
// last leg's file.
|
||||
trajPath := ""
|
||||
if cfg.trajDir != "" {
|
||||
if err := os.MkdirAll(cfg.trajDir, 0o755); err != nil {
|
||||
r.Note = "trajectory dir: " + err.Error()
|
||||
return r
|
||||
runTrajDir, dropTraj, err := answerTrajectoryDir(cfg.trajDir, t.answerRoot != "")
|
||||
if err != nil {
|
||||
r.Note = "trajectory dir: " + err.Error()
|
||||
if t.answerRoot != "" {
|
||||
markAnswerAuditIncomplete(&r, err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
defer dropTraj()
|
||||
|
||||
work, err := taskWorkdir(cfg, t.ID)
|
||||
if err != nil {
|
||||
@@ -562,14 +550,14 @@ func runTask(cfg suiteConfig, t task) result {
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
|
||||
if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) {
|
||||
if err := copyDir(seed, work); err != nil {
|
||||
r.Note = "copy seed: " + err.Error()
|
||||
return r
|
||||
}
|
||||
pinTapeTimes(cfg, work)
|
||||
if err := copyTaskSeed(cfg, t, work); err != nil {
|
||||
r.Note = "copy seed: " + err.Error()
|
||||
return r
|
||||
}
|
||||
if err := stageAnswerIsolation(t.answerRoot, work); err != nil {
|
||||
r.Note = "isolate answers: " + err.Error()
|
||||
return r
|
||||
}
|
||||
|
||||
extraEnv, dropState, seedNote := taskExperimentEnv(cfg, t, work)
|
||||
defer dropState()
|
||||
if seedNote != "" {
|
||||
@@ -590,19 +578,23 @@ func runTask(cfg suiteConfig, t task) result {
|
||||
startedAt := time.Now()
|
||||
snap, dropSnapshots := attachSnapshotter(cfg, t, work, startedAt)
|
||||
defer dropSnapshots()
|
||||
runErr := runSegments(ctx, cfg, t, work, cfg.trajDir, extraEnv, &r)
|
||||
runErr := runSegments(ctx, cfg, t, work, runTrajDir, extraEnv, &r)
|
||||
r.WallMs = time.Since(startedAt).Milliseconds()
|
||||
var taken []checkpoint
|
||||
if snap != nil {
|
||||
taken = snap.halt()
|
||||
}
|
||||
if err := removeAnswerIsolation(t.answerRoot, work); err != nil {
|
||||
r.Note = "remove answer isolation config: " + err.Error()
|
||||
return r
|
||||
}
|
||||
|
||||
mtr.record(&r)
|
||||
if trajPath = lastSegmentTrajectory(cfg.trajDir, t.ID, r.Segments); trajPath != "" {
|
||||
if summary, err := summarizeTrajectory(trajPath); err == nil {
|
||||
r.Trajectory = summary
|
||||
}
|
||||
applyMemoryStats(&r, trajPath, t)
|
||||
if trajPath = lastSegmentTrajectory(runTrajDir, t.ID, r.Segments); trajPath != "" {
|
||||
recordTaskTrajectory(&r, trajPath, t)
|
||||
}
|
||||
if !auditAnswerRun(&r, runTrajDir, t) {
|
||||
return r
|
||||
}
|
||||
// A killed child never writes metrics, so the deadline is the only place
|
||||
// this failure mode is still observable.
|
||||
|
||||
@@ -59,12 +59,11 @@ func seedTaskMemory(taskDir, work, stateHome string) error {
|
||||
// cleanup drops the root; note reports a failure without aborting the run.
|
||||
func taskExperimentEnv(cfg suiteConfig, t task, work string) (env []string, cleanup func(), note string) {
|
||||
cleanup = func() {}
|
||||
if cfg.policy == "memory-off" {
|
||||
memoryOff := cfg.policy == "memory-off"
|
||||
if memoryOff {
|
||||
env = append(env, "REASONIX_EXPERIMENT_NO_MEMORY=1")
|
||||
}
|
||||
// Named without the task id: a run that goes looking for its own name in
|
||||
// the temp root must not find the directory holding its session either.
|
||||
stateHome, err := os.MkdirTemp("", "e2ebench-state-")
|
||||
stateHome, err := makeStateHome(work)
|
||||
if err != nil {
|
||||
return env, cleanup, "state root: " + err.Error()
|
||||
}
|
||||
@@ -79,12 +78,69 @@ func taskExperimentEnv(cfg suiteConfig, t task, work string) (env []string, clea
|
||||
}
|
||||
cleanup = func() { _ = os.RemoveAll(stateHome); _ = os.RemoveAll(tmpDir) }
|
||||
env = append(env, "TMPDIR="+tmpDir)
|
||||
// The off arm's treatment is that no memory exists. Hiding only the tool
|
||||
// leaves the seeded files on disk for a shell command to read.
|
||||
if memoryOff {
|
||||
return env, cleanup, ""
|
||||
}
|
||||
if err := seedTaskMemory(t.dir, work, stateHome); err != nil {
|
||||
return env, cleanup, "memory seed: " + err.Error()
|
||||
}
|
||||
return env, cleanup, ""
|
||||
}
|
||||
|
||||
// benchStateBase is the directory every run's state root is made in. It is
|
||||
// outside the temp root holding the workdirs: a sibling of the workdir is one
|
||||
// `cd ..` and a find away from a run hunting for its seeded answers.
|
||||
var benchStateBase = func() (string, error) {
|
||||
cache, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(cache, "reasonix-bench-state"), nil
|
||||
}
|
||||
|
||||
// makeStateHome creates one run's state root. Its name is random and carries
|
||||
// neither the task id nor any part of the workdir's name, so a run searching
|
||||
// the disk for its own name does not find the directory holding its session.
|
||||
func makeStateHome(work string) (string, error) {
|
||||
base, err := benchStateBase()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
home, err := os.MkdirTemp(base, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if within(resolvedPath(filepath.Dir(work)), resolvedPath(home)) {
|
||||
_ = os.RemoveAll(home)
|
||||
return "", fmt.Errorf("%s is inside the workdir's parent %s", home, filepath.Dir(work))
|
||||
}
|
||||
return home, nil
|
||||
}
|
||||
|
||||
func resolvedPath(p string) string {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(p); err == nil {
|
||||
p = resolved
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// within reports whether path is dir or lies below it.
|
||||
func within(dir, path string) bool {
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
// applyMemoryStats folds one trajectory's recall behavior into the result row.
|
||||
func applyMemoryStats(r *result, trajPath string, t task) {
|
||||
stats := scanMemoryRecall(trajPath, t.MemoryMarkers, t.MemoryMarkersPrefix)
|
||||
|
||||
@@ -94,9 +94,10 @@ func TestSeedTaskMemoryBuildsIsolatedStateRoot(t *testing.T) {
|
||||
// its work dir, and one observed run found exactly that by grepping the store
|
||||
// for its own task id while looking for an answer it was not meant to have.
|
||||
func TestTaskExperimentEnvIsolatesEveryRun(t *testing.T) {
|
||||
isolateStateBase(t)
|
||||
roots := map[string]bool{}
|
||||
for _, id := range []string{"nosol-absent-oracle", "fix-add-bug"} {
|
||||
env, drop, note := taskExperimentEnv(suiteConfig{}, task{ID: id, dir: testenv.TempDir(t)}, testenv.TempDir(t))
|
||||
env, drop, note := taskExperimentEnv(suiteConfig{}, task{ID: id, dir: testenv.TempDir(t)}, nestedWorkdir(t))
|
||||
defer drop()
|
||||
if note != "" {
|
||||
t.Fatalf("%s: %s", id, note)
|
||||
@@ -131,3 +132,148 @@ func TestTaskExperimentEnvIsolatesEveryRun(t *testing.T) {
|
||||
t.Fatalf("two tasks did not get four distinct roots: %v", roots)
|
||||
}
|
||||
}
|
||||
|
||||
// isolateStateBase points state roots at a directory of the test's own, so the
|
||||
// suite never writes into the operator's user cache.
|
||||
func isolateStateBase(t *testing.T) string {
|
||||
t.Helper()
|
||||
base := testenv.TempDir(t)
|
||||
prev := benchStateBase
|
||||
benchStateBase = func() (string, error) { return base, nil }
|
||||
t.Cleanup(func() { benchStateBase = prev })
|
||||
return base
|
||||
}
|
||||
|
||||
// nestedWorkdir is a workdir whose parent is private to the test, so a state
|
||||
// base made by isolateStateBase is not inside it.
|
||||
func nestedWorkdir(t *testing.T) string {
|
||||
t.Helper()
|
||||
work := filepath.Join(testenv.TempDir(t), "work")
|
||||
if err := os.MkdirAll(work, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return work
|
||||
}
|
||||
|
||||
func stateHomeOf(env []string) string {
|
||||
for _, e := range env {
|
||||
if after, ok := strings.CutPrefix(e, "REASONIX_STATE_HOME="); ok {
|
||||
return after
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func seededTask(t *testing.T) task {
|
||||
t.Helper()
|
||||
dir := testenv.TempDir(t)
|
||||
for _, seed := range []string{"project/fact.md", "global/pref.md"} {
|
||||
p := filepath.Join(dir, "memory", seed)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("---\nname: x\ndescription: y\n---\n\nbody\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return task{ID: "mb-exact", dir: dir}
|
||||
}
|
||||
|
||||
func memoryFilesUnder(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
var found []string
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && strings.HasSuffix(p, ".md") {
|
||||
found = append(found, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// The off arm's treatment is that no memory exists, not that the memory tool
|
||||
// is hidden: a seeded store left on disk is one shell command away.
|
||||
func TestMemoryOffArmSeedsNoMemory(t *testing.T) {
|
||||
isolateStateBase(t)
|
||||
tk := seededTask(t)
|
||||
|
||||
env, drop, note := taskExperimentEnv(suiteConfig{policy: "memory-off"}, tk, nestedWorkdir(t))
|
||||
defer drop()
|
||||
if note != "" {
|
||||
t.Fatal(note)
|
||||
}
|
||||
home := stateHomeOf(env)
|
||||
if home == "" {
|
||||
t.Fatal("memory-off arm ran against the operator's own store root")
|
||||
}
|
||||
if files := memoryFilesUnder(t, home); len(files) != 0 {
|
||||
t.Fatalf("memory-off state root holds seeded memory: %v", files)
|
||||
}
|
||||
|
||||
onEnv, onDrop, onNote := taskExperimentEnv(suiteConfig{}, tk, nestedWorkdir(t))
|
||||
defer onDrop()
|
||||
if onNote != "" {
|
||||
t.Fatal(onNote)
|
||||
}
|
||||
if files := memoryFilesUnder(t, stateHomeOf(onEnv)); len(files) != 2 {
|
||||
t.Fatalf("memory-on arm must carry both seeds, got %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
// The state root must not sit beside the workdir: a run that walks up from its
|
||||
// workspace and searches the temp root would otherwise reach the seeded store.
|
||||
func TestStateHomeIsOutsideWorkdirParent(t *testing.T) {
|
||||
work, err := taskWorkdir(suiteConfig{}, "mb-exact")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(work) })
|
||||
env, drop, note := taskExperimentEnv(suiteConfig{}, seededTask(t), work)
|
||||
defer drop()
|
||||
if note != "" {
|
||||
t.Fatal(note)
|
||||
}
|
||||
home := stateHomeOf(env)
|
||||
if home == "" {
|
||||
t.Fatal("no state root")
|
||||
}
|
||||
if within(resolvedPath(filepath.Dir(work)), resolvedPath(home)) {
|
||||
t.Fatalf("state root %s is inside the workdir's parent %s", home, filepath.Dir(work))
|
||||
}
|
||||
if strings.Contains(filepath.Base(home), "e2ebench") || strings.Contains(home, "mb-exact") {
|
||||
t.Errorf("state root %s is named after the workdir or task", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeStateHomeRefusesWorkdirParent(t *testing.T) {
|
||||
parent := testenv.TempDir(t)
|
||||
prev := benchStateBase
|
||||
benchStateBase = func() (string, error) { return filepath.Join(parent, "state"), nil }
|
||||
t.Cleanup(func() { benchStateBase = prev })
|
||||
if home, err := makeStateHome(filepath.Join(parent, "work")); err == nil {
|
||||
t.Fatalf("state root %s accepted beside the workdir", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsTrajectoryDirAnchorsRelativePaths(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := absTrajectoryDir("t-off"), filepath.Join(cwd, "t-off"); got != want {
|
||||
t.Fatalf("relative -trajectories resolved to %q, want %q", got, want)
|
||||
}
|
||||
abs := filepath.Join(testenv.TempDir(t), "t-on")
|
||||
if got := absTrajectoryDir(abs); got != abs {
|
||||
t.Fatalf("absolute -trajectories changed: %q", got)
|
||||
}
|
||||
if got := absTrajectoryDir(""); got != "" {
|
||||
t.Fatalf("unset -trajectories became %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// witness is one memorybench task's correct answer, as a set of files to write
|
||||
// into a staged seed. Each answer is derived from the task's own facts, never
|
||||
// from verify.sh: an expected value read out of the grader accepts whatever the
|
||||
// grader accepts, which is the loop this table exists to break.
|
||||
type witness struct {
|
||||
// from names the fact the answer comes from, never the grader.
|
||||
from string
|
||||
files map[string]string
|
||||
}
|
||||
|
||||
// memorybenchWitness holds the other half of the authoring rule corpus_test.go
|
||||
// states: "One that can never pass is indistinguishable from a task with no
|
||||
// solution." These tasks ship no solution/, so the reference-solution test skips
|
||||
// them and nothing else asserts that their graders accept a correct answer.
|
||||
var memorybenchWitness = map[string]witness{
|
||||
"mb-cjk": {
|
||||
from: "memory/project/migration-naming.md: migrations are `MEMKEY-CJK5T-<description>.sql` at the repo root",
|
||||
files: map[string]string{"MEMKEY-CJK5T-add-users.sql": ""},
|
||||
},
|
||||
"mb-conflict": {
|
||||
from: "memory/project/deploy-region.md: this project deploys to eu-central-1, overriding the global us-east-1",
|
||||
files: map[string]string{"answer.txt": "eu-central-1\n"},
|
||||
},
|
||||
"mb-contradiction": {
|
||||
from: "memory/project/dependency-tooling.md (migrated to pnpm) and the seeded pnpm-lock.yaml",
|
||||
files: map[string]string{"answer.txt": "pnpm install\n"},
|
||||
},
|
||||
"mb-distractor": {
|
||||
from: "memory/project/fast-check-command.md among the distractors",
|
||||
files: map[string]string{"answer.txt": "make check-fast --tag=MEMKEY-DIST5Q\n"},
|
||||
},
|
||||
"mb-exact": {
|
||||
from: "memory/project/fast-check-command.md",
|
||||
files: map[string]string{"answer.txt": "make check-fast --tag=MEMKEY-EXACT7Q\n"},
|
||||
},
|
||||
"mb-generic": {
|
||||
from: "the prompt, plus the seeded util.py that already defines sub",
|
||||
files: map[string]string{"util.py": "def sub(a, b):\n return a - b\n\n\ndef add(a, b):\n return a + b\n"},
|
||||
},
|
||||
"mb-history": {
|
||||
from: "the seeded config/server.conf, whose line the prompt says to copy unchanged",
|
||||
files: map[string]string{"answer.txt": "timeout_seconds = 47 # MEMKEY-HIST6M tuned for p99\n"},
|
||||
},
|
||||
"mb-paraphrase": {
|
||||
from: "memory/project/package-manager.md",
|
||||
files: map[string]string{"answer.txt": "pnpm install --tag=MEMKEY-PARA3X\n"},
|
||||
},
|
||||
"mb-pin": {
|
||||
from: "memory/project/file-header-rule.md: a new text file's first line must be the stamp",
|
||||
files: map[string]string{"notes.txt": "# MEMKEY-PIN4D\nhello\n"},
|
||||
},
|
||||
"mb-stale": {
|
||||
from: "the seeded RELEASE file, which supersedes the branch memory still names",
|
||||
files: map[string]string{"answer.txt": "release/1.21\n"},
|
||||
},
|
||||
"mb-symbol": {
|
||||
from: "memory/project/retry-config-location.md: the key lives in policy/retry.toml, never .env",
|
||||
files: map[string]string{"policy/retry.toml": "# MEMKEY-SYM9K policy file\nmax_backoff_ms = 250\n"},
|
||||
},
|
||||
"mb-update": {
|
||||
from: "memory/project/api-base.md: the v2 host replaced the dead v1 host",
|
||||
files: map[string]string{"answer.txt": "https://api.v2.example\n"},
|
||||
},
|
||||
"mb-v1miss-cjk-short": {
|
||||
from: "memory/project/canary-ratio.md",
|
||||
files: map[string]string{"answer.txt": "7%\n"},
|
||||
},
|
||||
"mb-v1miss-crosslang": {
|
||||
from: "memory/project/deploy-approver.md",
|
||||
files: map[string]string{"answer.txt": "release-captain-zhao\n"},
|
||||
},
|
||||
"mb-v1miss-symbol": {
|
||||
from: "memory/project/idle-conn-config.md: the key lives in conf/server.toml, never env",
|
||||
files: map[string]string{"conf/server.toml": "# MEMKEY-VM1SYM\nmax_idle_conn_secs = 30\n"},
|
||||
},
|
||||
}
|
||||
|
||||
// A grader that rejects a correct answer scores every honest run as a failure,
|
||||
// so a corpus of them teaches that nothing is ever accepted. Every memorybench
|
||||
// task ships memory/ facts instead of a solution/, so the witness table above
|
||||
// is the only reference answer available.
|
||||
func TestMemorybenchGradersAcceptTheReferenceAnswer(t *testing.T) {
|
||||
for _, bin := range []string{"bash", "python3"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
tasks, err := loadTasks(memorybenchDir)
|
||||
if err != nil {
|
||||
t.Fatalf("load corpus: %v", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("no memorybench tasks found")
|
||||
}
|
||||
for _, task := range tasks {
|
||||
w, ok := memorybenchWitness[task.ID]
|
||||
if !ok {
|
||||
t.Errorf("%s has no witness: give it a correct answer derived from its own facts", task.ID)
|
||||
continue
|
||||
}
|
||||
t.Run(task.ID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
work := stageSeed(t, task.dir)
|
||||
for rel, content := range w.files {
|
||||
path := filepath.Join(work, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir for %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if err := gradeSeed(t, work); err != nil {
|
||||
t.Fatalf("the grader rejects the answer its own facts imply (%s): %v", w.from, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for id := range memorybenchWitness {
|
||||
if _, err := os.Stat(filepath.Join(memorybenchDir, "tasks", id)); err != nil {
|
||||
t.Errorf("witness for %s has no task", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
)
|
||||
|
||||
func loadTasks(suite string) ([]task, error) {
|
||||
tasksDir := filepath.Join(suite, "tasks")
|
||||
entries, err := os.ReadDir(tasksDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tasks []task
|
||||
hasAnswerMaterial := false
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(tasksDir, e.Name())
|
||||
var t task
|
||||
data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
}
|
||||
if _, err := toml.Decode(string(data), &t); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
}
|
||||
t.ID = e.Name()
|
||||
t.dir = dir
|
||||
if dirExists(filepath.Join(dir, "memory")) {
|
||||
hasAnswerMaterial = true
|
||||
}
|
||||
if t.TimeoutSec == 0 {
|
||||
t.TimeoutSec = 240
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
if hasAnswerMaterial {
|
||||
for i := range tasks {
|
||||
tasks[i].answerRoot = tasksDir
|
||||
}
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func copyTaskSeed(cfg suiteConfig, t task, work string) error {
|
||||
seed := filepath.Join(t.dir, "workdir")
|
||||
if !dirExists(seed) {
|
||||
return nil
|
||||
}
|
||||
if err := copyDir(seed, work); err != nil {
|
||||
return err
|
||||
}
|
||||
pinTapeTimes(cfg, work)
|
||||
return nil
|
||||
}
|
||||
@@ -89,3 +89,16 @@ func dispatchOfflineMode(mode string, a offlineModeArgs) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// absTrajectoryDir anchors -trajectories to the operator's cwd. Each task's
|
||||
// child runs with the task workdir as its cwd, so a relative value would name
|
||||
// a directory under every workdir instead of the one the operator meant.
|
||||
func absTrajectoryDir(dir string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
if abs, err := filepath.Abs(dir); err == nil {
|
||||
return abs
|
||||
}
|
||||
return dir
|
||||
}
|
||||
@@ -22,6 +22,9 @@ func runTrials(cfg suiteConfig, t task, total *int) []result {
|
||||
r.Trial = trial
|
||||
*total += r.PromptTokens + r.CompletionTokens
|
||||
out = append(out, r)
|
||||
if answerLeakDetected(r) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -282,6 +282,11 @@ func resolveKernelLanguage(cfg *config.Config) string {
|
||||
// assemble builds the hub this host serves: one pane on the workspace it was
|
||||
// launched in, carrying the capabilities a local window may exercise.
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS) (*serve.Hub, error) {
|
||||
// The one-time upgrades belong to whichever entry point starts first; a
|
||||
// person who only ever opens the window would otherwise never get them.
|
||||
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: config upgrade:", err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -461,4 +466,7 @@ func grantHostCapabilities(srv *serve.Server) {
|
||||
srv.AllowAccountAuth()
|
||||
srv.AllowProviderEdit()
|
||||
srv.AllowLocalDesktop()
|
||||
if telemetry.ReleaseVersion(version) {
|
||||
srv.AllowMarketInstallReport()
|
||||
}
|
||||
}
|
||||
@@ -471,3 +471,28 @@ type stubTray struct{}
|
||||
func (stubTray) IconLive() bool { return false }
|
||||
func (stubTray) TrayFold() traystate.State { return traystate.State{} }
|
||||
func (stubTray) ApplyTrayPrefs(serve.TrayPrefs) {}
|
||||
|
||||
// A person who only ever opens the window still gets the one-time upgrades:
|
||||
// the shipped "auto" a v6 file carries is released before the first pane is
|
||||
// built, so that pane opens in the default the sandbox and trust decide.
|
||||
func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
|
||||
// Its own home: another test's launch already recorded the release in the
|
||||
// package's shared one, and a recorded release is not run again.
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("config_version = 6\n[desktop]\ndefault_tool_approval_mode = \"auto\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
defer hub.Shutdown()
|
||||
if got := config.LoadForEdit(path).DesktopDefaultToolApprovalMode(); got != "" {
|
||||
t.Fatalf("after the window started, the desktop posture is %q, want the shipped auto released", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var cliChannelSecrets = []string{"STUDIO_NPM_TOKEN", "STUDIO_HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
// The 1.x line reads these repository secrets; studio must never touch them.
|
||||
var legacyRegistrySecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(data, &document); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
if len(document.Content) != 1 {
|
||||
t.Fatalf("%s: not a single YAML document", path)
|
||||
}
|
||||
return document.Content[0]
|
||||
}
|
||||
|
||||
func jobsOf(t *testing.T, root *yaml.Node) *yaml.Node {
|
||||
t.Helper()
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil || jobs.Kind != yaml.MappingNode {
|
||||
t.Fatal("workflow has no jobs mapping")
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
func permissionsIssue(node *yaml.Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return "permissions is a " + node.Value + " shorthand, not a mapping"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// secretsRefs reports every way a workflow can reach a named secret: dotted
|
||||
// and subscripted access, and the whole-context forms that expose them all.
|
||||
var secretsWholeContext = regexp.MustCompile(`\bsecrets\b\s*(?:[^.\[\w\s]|$)|toJSON\(\s*secrets\s*\)|\bsecrets\s*\[\s*[^'"\s]`)
|
||||
|
||||
func secretRefPattern(name string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`\bsecrets\s*(?:\.\s*` + name + `\b|\[\s*['"]` + name + `['"]\s*\])`)
|
||||
}
|
||||
|
||||
func scalarsOf(node *yaml.Node, out *[]string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
*out = append(*out, node.Value)
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
scalarsOf(child, out)
|
||||
}
|
||||
}
|
||||
|
||||
func refersTo(node *yaml.Node, names []string) (string, bool) {
|
||||
var scalars []string
|
||||
scalarsOf(node, &scalars)
|
||||
for _, text := range scalars {
|
||||
for _, name := range names {
|
||||
if secretRefPattern(name).MatchString(text) {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
if secretsWholeContext.MatchString(text) && regexp.MustCompile(`\$\{\{`).MatchString(text) {
|
||||
return "the whole secrets context", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
job := mappingValue(jobs, "cli-channels")
|
||||
if job == nil {
|
||||
t.Fatal("cli-channels job is missing")
|
||||
}
|
||||
if got := mappingScalar(job, "environment"); got != "studio-cli-channels" {
|
||||
t.Fatalf("cli-channels environment = %q, want studio-cli-channels", got)
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil || permissions.Kind != yaml.MappingNode {
|
||||
t.Fatal("cli-channels must declare its permissions as a mapping")
|
||||
}
|
||||
want := map[string]string{"contents": "read", "id-token": "write"}
|
||||
if len(permissions.Content) != 2*len(want) {
|
||||
t.Fatalf("cli-channels permissions has %d entries, want exactly %v", len(permissions.Content)/2, want)
|
||||
}
|
||||
for scope, level := range want {
|
||||
if got := mappingScalar(permissions, scope); got != level {
|
||||
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
|
||||
}
|
||||
}
|
||||
if !nodeContains(job, "environments/studio-cli-channels") || !nodeContains(job, "required_reviewers") {
|
||||
t.Fatal("cli-channels must verify the environment's required reviewers before publishing")
|
||||
}
|
||||
assertGateBeforePublishing(t, job)
|
||||
for _, name := range cliChannelSecrets {
|
||||
if !secretRefPattern(name).MatchString(scalarString(job)) {
|
||||
t.Fatalf("cli-channels does not read secrets.%s", name)
|
||||
}
|
||||
}
|
||||
if name, found := refersTo(job, legacyRegistrySecrets); found {
|
||||
t.Fatalf("cli-channels reads %s; it must use the environment-only STUDIO_* names", name)
|
||||
}
|
||||
}
|
||||
|
||||
func scalarString(node *yaml.Node) string {
|
||||
var scalars []string
|
||||
scalarsOf(node, &scalars)
|
||||
return strings.Join(scalars, "\n")
|
||||
}
|
||||
|
||||
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
if top := mappingValue(root, "permissions"); top != nil {
|
||||
if msg := permissionsIssue(top); msg != "" {
|
||||
t.Errorf("%s: workflow-level %s", file, msg)
|
||||
} else if mappingScalar(top, "id-token") != "" {
|
||||
t.Errorf("%s: id-token at workflow level", file)
|
||||
}
|
||||
}
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil {
|
||||
continue
|
||||
}
|
||||
if msg := permissionsIssue(permissions); msg != "" {
|
||||
t.Errorf("%s job %s: %s", file, name, msg)
|
||||
continue
|
||||
}
|
||||
if mappingScalar(permissions, "id-token") != "" && !(filepath.Base(file) == "release-studio.yml" && name == "cli-channels") {
|
||||
t.Errorf("%s job %s requests id-token; only release-studio.yml cli-channels may", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
if filepath.Base(file) == "release-studio.yml" {
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
if name == "cli-channels" {
|
||||
continue
|
||||
}
|
||||
if what, found := refersTo(job, guarded); found {
|
||||
t.Errorf("release-studio.yml job %s reaches %s; only cli-channels may", name, what)
|
||||
}
|
||||
}
|
||||
if what, found := refersTo(mappingValue(root, "env"), guarded); found {
|
||||
t.Errorf("release-studio.yml workflow env reaches %s", what)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name, found := refersTo(root, cliChannelSecrets); found {
|
||||
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"${{ secrets.STUDIO_NPM_TOKEN }}",
|
||||
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
|
||||
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
|
||||
"${{ toJSON(secrets) }}",
|
||||
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
|
||||
} {
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := refersTo(doc.Content[0], cliChannelSecrets); !found {
|
||||
t.Errorf("reference form not detected: %s", text)
|
||||
}
|
||||
}
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: echo ${{ secrets.R2_BUCKET }}\n"), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := refersTo(doc.Content[0], cliChannelSecrets); found {
|
||||
t.Error("an unrelated secret was reported")
|
||||
}
|
||||
}
|
||||
|
||||
func assertGateBeforePublishing(t *testing.T, job *yaml.Node) {
|
||||
t.Helper()
|
||||
steps := mappingValue(job, "steps")
|
||||
if steps == nil || steps.Kind != yaml.SequenceNode {
|
||||
t.Fatal("cli-channels has no steps")
|
||||
}
|
||||
gate := -1
|
||||
for i, step := range steps.Content {
|
||||
if nodeContains(mappingValue(step, "run"), "environments/studio-cli-channels") {
|
||||
gate = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if gate < 0 {
|
||||
t.Fatal("cli-channels has no environment check step")
|
||||
}
|
||||
for i, step := range steps.Content[:gate] {
|
||||
if run := mappingScalar(step, "run"); run != "" || mappingValue(step, "env") != nil {
|
||||
t.Errorf("step %d runs or gets credentials before the environment check", i)
|
||||
}
|
||||
}
|
||||
for i, step := range steps.Content {
|
||||
if i > gate {
|
||||
continue
|
||||
}
|
||||
for _, name := range append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...) {
|
||||
if nodeContains(step, name) {
|
||||
t.Errorf("step %d references %s before or at the environment check", i, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentCheckStepFailsClosed(t *testing.T) {
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "cli-channels")
|
||||
var script string
|
||||
for _, step := range mappingValue(job, "steps").Content {
|
||||
if run := mappingScalar(step, "run"); strings.Contains(run, "environments/studio-cli-channels") {
|
||||
script = run
|
||||
break
|
||||
}
|
||||
}
|
||||
if script == "" {
|
||||
t.Fatal("environment check step not found")
|
||||
}
|
||||
reviewers := `{"type":"required_reviewers","reviewers":[{"a":1}]}`
|
||||
cases := []struct {
|
||||
name, response string
|
||||
pass bool
|
||||
}{
|
||||
{"one reviewer", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[" + reviewers + "]}", true},
|
||||
{"no rules", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}", false},
|
||||
{"two JSON documents", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}{\"protection_rules\":[]}", false},
|
||||
{"empty body", "HTTP/2.0 200 OK\n\n", false},
|
||||
{"non-numeric body", "HTTP/2.0 200 OK\n\n\"x\"", false},
|
||||
{"missing environment", "HTTP/2.0 404 Not Found\n\n{\"message\":\"Not Found\"}", false},
|
||||
{"server error", "HTTP/2.0 500 Error\n\n{}", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
dir := t.TempDir()
|
||||
fake := "#!/bin/sh\ncat <<'EOF'\n" + tc.response + "\nEOF\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fake), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(bash, "-c", script+"\necho PASSED")
|
||||
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "REPO=x/y", "GH_TOKEN=x", "GITHUB_ACTOR=t")
|
||||
out, err := cmd.CombinedOutput()
|
||||
passed := err == nil && strings.Contains(string(out), "PASSED")
|
||||
if passed != tc.pass {
|
||||
t.Errorf("%s: passed=%v, want %v\n%s", tc.name, passed, tc.pass, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,13 @@ func TestManifestListsEachSignedDeltaFromTheMirror(t *testing.T) {
|
||||
if err := os.WriteFile(index, []byte("index bytes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", deltaDir); err == nil {
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", deltaDir, true); err == nil {
|
||||
t.Fatal("an unsigned delta index was published")
|
||||
}
|
||||
if err := os.WriteFile(index+".minisig", []byte("sig"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", deltaDir); err != nil {
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", deltaDir, true); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
|
||||
@@ -31,13 +31,16 @@ func main() {
|
||||
if len(os.Args) == 5 {
|
||||
deltaDir = os.Args[4]
|
||||
}
|
||||
if err := run(os.Args[1], os.Args[2], os.Args[3], deltaDir); err != nil {
|
||||
// HAS_R2 is the release workflow's own gate on uploading the mirror, so the
|
||||
// manifest names mirror addresses exactly when that upload will run.
|
||||
mirrored := os.Getenv("HAS_R2") == "true"
|
||||
if err := run(os.Args[1], os.Args[2], os.Args[3], deltaDir, mirrored); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(dir, version, tag, deltaDir string) error {
|
||||
func run(dir, version, tag, deltaDir string, mirrored bool) error {
|
||||
repo := os.Getenv("GITHUB_REPOSITORY")
|
||||
if strings.TrimSpace(repo) == "" {
|
||||
return fmt.Errorf("studio-manifest: GITHUB_REPOSITORY is unset, so asset URLs cannot be built")
|
||||
@@ -66,8 +69,8 @@ func run(dir, version, tag, deltaDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
|
||||
asset := update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
|
||||
asset := releaseAsset(repo, tag, name, mirrored)
|
||||
asset.Size, asset.SHA256 = size, sum
|
||||
// Downloads is what a person is offered, so it lists what installs
|
||||
// itself. A portable archive stays a release asset, but offering it
|
||||
// beside the installer only asks the reader to choose blind.
|
||||
@@ -111,6 +114,21 @@ func run(dir, version, tag, deltaDir string) error {
|
||||
return os.WriteFile(filepath.Join(dir, "latest.json"), append(b, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// releaseAsset addresses one artifact. The mirror serves it first when there is
|
||||
// one: GitHub's release CDN is what a CN route reaches slowest, and it stays as
|
||||
// the fallback for a mirror that is down.
|
||||
func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
|
||||
github := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
|
||||
if !mirrored {
|
||||
return update.Asset{URL: github, Sig: github + ".minisig"}
|
||||
}
|
||||
mirror := fmt.Sprintf("%s/%s/%s", update.StudioMirror, tag, name)
|
||||
return update.Asset{
|
||||
URL: mirror, Sig: mirror + ".minisig",
|
||||
Fallback: github, FallbackSig: github + ".minisig",
|
||||
}
|
||||
}
|
||||
|
||||
// installable reports whether an artifact installs itself rather than expecting
|
||||
// the reader to place it: a disk image to drag from, an installer to run, a
|
||||
// package for dpkg.
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", ""); err != nil {
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", false); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", ""); err != nil {
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", false); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
@@ -128,7 +128,64 @@ func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
|
||||
|
||||
func TestEmptyDirIsAFailedRelease(t *testing.T) {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := run(t.TempDir(), "v0.1.0", "studio-v0.1.0", ""); err == nil {
|
||||
if err := run(t.TempDir(), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
|
||||
t.Fatal("a release with no artifacts must fail, not publish an empty manifest")
|
||||
}
|
||||
}
|
||||
|
||||
// A mirrored release is fetched from the mirror first, with GitHub kept as the
|
||||
// address a client falls back to; every full artifact and its signature alike.
|
||||
func TestMirroredManifestPutsTheMirrorFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe",
|
||||
"ReasonixStudio-darwin-universal.zip",
|
||||
"ReasonixStudio-linux-amd64.deb",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var all []update.Asset
|
||||
for _, group := range []map[string]update.Asset{m.Platforms, m.Downloads, m.NativePackages} {
|
||||
for _, a := range group {
|
||||
all = append(all, a)
|
||||
}
|
||||
}
|
||||
if len(all) != 6 {
|
||||
t.Fatalf("assets = %d, want 3 platforms, 2 downloads and 1 package", len(all))
|
||||
}
|
||||
for _, a := range all {
|
||||
name := a.URL[strings.LastIndex(a.URL, "/")+1:]
|
||||
mirror := update.StudioMirror + "/studio-v0.1.0/" + name
|
||||
github := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/studio-v0.1.0/" + name
|
||||
if a.URL != mirror || a.Sig != mirror+".minisig" {
|
||||
t.Errorf("%s: primary = %q / %q, want the mirror", name, a.URL, a.Sig)
|
||||
}
|
||||
if a.Fallback != github || a.FallbackSig != github+".minisig" {
|
||||
t.Errorf("%s: fallback = %q / %q, want the GitHub release", name, a.Fallback, a.FallbackSig)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(m.DownloadPage, "https://github.com/") {
|
||||
t.Errorf("download page = %q, want the GitHub release page", m.DownloadPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
|
||||
a := releaseAsset("o/r", "studio-v1", "ReasonixStudio-linux-amd64.deb", false)
|
||||
if !strings.HasPrefix(a.URL, "https://github.com/o/r/") || a.Fallback != "" || a.FallbackSig != "" {
|
||||
t.Fatalf("asset = %+v, want GitHub alone when nothing is mirrored", a)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"build:dist": "electron-builder",
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"freeze-live": "electron test/freeze_live.js",
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -29,6 +29,9 @@ class BrowserViews {
|
||||
// for this run only: a restart, or a different certificate, asks again.
|
||||
this.trusted = new Set();
|
||||
this.shown = "";
|
||||
// Moves with every show and hide, so a freeze that finishes after one does
|
||||
// not put away a page shown since.
|
||||
this.placed = 0;
|
||||
win.on("resize", () => {
|
||||
for (const [id, entry] of this.entries) if (id !== this.shown) this.putAway(entry.view);
|
||||
});
|
||||
@@ -142,6 +145,7 @@ class BrowserViews {
|
||||
// re-adding the view and setting the bounds it already has is work for
|
||||
// every one of those.
|
||||
if (this.shown === targetId && sameRect(entry.at, at)) return;
|
||||
this.placed++;
|
||||
for (const [id, other] of this.entries) {
|
||||
if (id !== targetId) this.putAway(other.view, other);
|
||||
}
|
||||
@@ -160,7 +164,31 @@ class BrowserViews {
|
||||
}
|
||||
}
|
||||
|
||||
// freeze answers with a picture of the page on screen, taken before it is put
|
||||
// away, for the window to draw in its place while something of its own is
|
||||
// over it. Only the page already shown to the person is ever taken, and the
|
||||
// picture goes to this window's page and nowhere else.
|
||||
async freeze() {
|
||||
const id = this.shown;
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry || entry.view.webContents.isDestroyed()) {
|
||||
this.hide();
|
||||
return "";
|
||||
}
|
||||
const turn = this.placed;
|
||||
let picture = "";
|
||||
try {
|
||||
const image = await entry.view.webContents.capturePage();
|
||||
if (!image.isEmpty()) picture = "data:image/jpeg;base64," + image.toJPEG(85).toString("base64");
|
||||
} catch {
|
||||
picture = "";
|
||||
}
|
||||
if (this.placed === turn && this.shown === id) this.hide();
|
||||
return picture;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.placed++;
|
||||
this.shown = "";
|
||||
for (const entry of this.entries.values()) this.putAway(entry.view, entry);
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ function onWindowClose(event) {
|
||||
// showWindow brings it back from wherever it went. Show alone is a no-op on a
|
||||
// window that is merely buried, so the focus is what actually raises it.
|
||||
function showWindow() {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
if (quitting || !win || win.isDestroyed()) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
reload?.revive();
|
||||
win.show();
|
||||
@@ -284,6 +284,7 @@ ipcMain.handle("browser:show", (event, targetId, rect) => {
|
||||
ipcMain.handle("browser:hide", (event) => {
|
||||
if (fromWindow(event)) browserViews?.hide();
|
||||
});
|
||||
ipcMain.handle("browser:freeze", (event) => (fromWindow(event) ? (browserViews?.freeze() ?? "") : ""));
|
||||
ipcMain.handle("browser:control", (event, targetId, action) => {
|
||||
if (fromWindow(event)) browserViews?.control(String(targetId), String(action));
|
||||
});
|
||||
@@ -363,6 +364,8 @@ if (!primary) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", showWindow);
|
||||
// macOS reopens an existing app through activation, without a second process.
|
||||
app.on("activate", showWindow);
|
||||
grants = stripPackageGrants(hostBinary, { ...where, execPath: process.execPath });
|
||||
if (grants?.stripped.length) {
|
||||
const note = `removed app-package grants that stop sandboxed processes loading: ${grants.stripped.join(", ")}`;
|
||||
|
||||
@@ -45,6 +45,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
// page, and the few controls a person has over a page they are watching.
|
||||
showBrowserView: (targetId, rect) => ipcRenderer.invoke("browser:show", String(targetId), rectOf(rect)),
|
||||
hideBrowserView: () => ipcRenderer.invoke("browser:hide"),
|
||||
freezeBrowserView: () => ipcRenderer.invoke("browser:freeze"),
|
||||
controlBrowserView: (targetId, action) => ipcRenderer.invoke("browser:control", String(targetId), String(action)),
|
||||
navigateBrowserView: (targetId, address) => ipcRenderer.invoke("browser:navigate", String(targetId), String(address)),
|
||||
// Why a page did not load, and a login a page or proxy asks for. The password
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { until } = require("./livekit");
|
||||
|
||||
async function activationChecks(win, current, check) {
|
||||
const { client, origin, tray } = current();
|
||||
const contents = win.webContents;
|
||||
await contents.executeJavaScript("window.__activationProbe = 'retained'");
|
||||
|
||||
for (const source of ["activate", "second-instance", "tray"]) {
|
||||
app.focus({ steal: true });
|
||||
const reopen = () => source === "tray" ? tray.icon.emit("click") : app.emit(source);
|
||||
win.hide();
|
||||
reopen();
|
||||
check(`${source} shows the hidden window`, win.isVisible());
|
||||
|
||||
// Continue even on a missing handler so each entry point is weighed.
|
||||
win.show();
|
||||
win.minimize();
|
||||
await until("window minimization", () => win.isMinimized());
|
||||
reopen();
|
||||
check(`${source} restores the minimized window`, await until("reactivation", () => !win.isMinimized() && win.isVisible(), 5000).then(() => true, () => false));
|
||||
win.restore();
|
||||
await until("window restoration", () => !win.isMinimized());
|
||||
|
||||
const cover = new BrowserWindow({ width: 300, height: 200, show: true });
|
||||
try {
|
||||
await cover.loadURL("data:text/html,<title>Activation test</title>");
|
||||
app.focus({ steal: true });
|
||||
cover.focus();
|
||||
await until("cover window focus", () => cover.isFocused());
|
||||
reopen();
|
||||
check(`${source} focuses the existing window`, await until("reactivation focus", () => win.isFocused(), 5000).then(() => true, () => false));
|
||||
reopen();
|
||||
reopen();
|
||||
} finally {
|
||||
cover.destroy();
|
||||
}
|
||||
check(`${source} reuses one window and renderer`,
|
||||
BrowserWindow.getAllWindows().length === 1 && current().win === win && win.webContents === contents);
|
||||
check(`${source} preserves in-memory renderer state`,
|
||||
await contents.executeJavaScript("window.__activationProbe") === "retained");
|
||||
check(`${source} preserves the running host`,
|
||||
current().client === client && current().origin === origin && !!(await client.trayPrefs()));
|
||||
}
|
||||
await contents.executeJavaScript("delete window.__activationProbe");
|
||||
app.once("before-quit", () => {
|
||||
win.hide();
|
||||
for (const source of ["activate", "second-instance"]) {
|
||||
app.emit(source);
|
||||
check(`${source} cannot reopen a quitting window`, !win.isVisible());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { activationChecks };
|
||||
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
// Freezes a real page in the real shell: the picture the window draws while its
|
||||
// own layer covers the page must be that page, taken before it is put away.
|
||||
// Run with `pnpm freeze-live`; it needs no kernel and no network.
|
||||
const { app, BrowserWindow, nativeImage } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const fails = [];
|
||||
const check = (name, ok, got) => {
|
||||
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 900, height: 700, show: true, webPreferences: { sandbox: true } });
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:%23eee'><h3>studio page</h3></body>");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
let loaded;
|
||||
const ready = new Promise((r) => (loaded = r));
|
||||
const page = views.create({
|
||||
partition: "freeze-live",
|
||||
url: "data:text/html,<body style='margin:0;background:%23c0392b;color:white;font:40px sans-serif'><p>guest page</p></body>",
|
||||
onEvent: (method) => method === "Page.loadEventFired" && loaded(),
|
||||
onClosed() {},
|
||||
onPopup() {},
|
||||
onDownload() {},
|
||||
});
|
||||
await page.send("Page.enable");
|
||||
await Promise.race([ready, wait(3000)]);
|
||||
const rect = { x: 100, y: 120, width: 600, height: 400 };
|
||||
views.show(page.targetId, rect);
|
||||
await wait(600);
|
||||
const view = views.entries.get(page.targetId).view;
|
||||
check("shown at the panel's rectangle", JSON.stringify(view.getBounds()) === JSON.stringify(rect), view.getBounds());
|
||||
const picture = await views.freeze();
|
||||
check("freeze answers with a jpeg of the page", picture.startsWith("data:image/jpeg;base64,") && picture.length > 1000, picture.slice(0, 40));
|
||||
const b = view.getBounds();
|
||||
check("the page is put away after the picture is taken", b.x < 0 && views.shown === "", { b, shown: views.shown });
|
||||
const img = nativeImage.createFromDataURL(picture);
|
||||
const bmp = img.toBitmap();
|
||||
const size = img.getSize();
|
||||
const mid = ((size.height >> 1) * size.width + (size.width >> 1)) * 4;
|
||||
check("the picture is the guest page's own pixels", bmp[mid + 2] > 150 && bmp[mid + 1] < 100, [bmp[mid + 2], bmp[mid + 1], bmp[mid]]);
|
||||
// A show that lands while a freeze is still taking its picture wins.
|
||||
views.show(page.targetId, rect);
|
||||
await wait(300);
|
||||
const racing = views.freeze();
|
||||
views.show(page.targetId, { ...rect, width: 601 });
|
||||
await racing;
|
||||
check("a freeze overtaken by a show leaves the page shown", views.shown === page.targetId && view.getBounds().x === 100, { shown: views.shown, b: view.getBounds() });
|
||||
views.hide();
|
||||
check("with nothing shown, freeze answers with no picture", (await views.freeze()) === "", "");
|
||||
page.close();
|
||||
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
|
||||
app.exit(fails.length ? 1 : 0);
|
||||
});
|
||||
@@ -14,6 +14,10 @@ const { app, BrowserWindow, Menu } = require("electron");
|
||||
// it. A home that already carries a configuration is left exactly as it is.
|
||||
seedHome(process.env.REASONIX_HOME);
|
||||
const { current } = require("../src/main.js");
|
||||
const { activationChecks } = require("./activation.js");
|
||||
|
||||
// macOS can activate the app before the host has supplied its window.
|
||||
if (process.platform === "darwin") app.emit("activate");
|
||||
|
||||
function seedHome(home) {
|
||||
if (!home || fs.existsSync(path.join(home, "config.toml"))) return;
|
||||
@@ -80,7 +84,7 @@ async function run() {
|
||||
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
|
||||
}))()`);
|
||||
|
||||
const verbs = ["closeWindow", "controlBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "openExternal", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow"];
|
||||
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
|
||||
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
|
||||
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
|
||||
@@ -199,7 +203,8 @@ async function trayChecks(win) {
|
||||
visible: win.isVisible(),
|
||||
});
|
||||
|
||||
win.show();
|
||||
if (process.platform === "darwin") await activationChecks(win, current, check);
|
||||
else win.show();
|
||||
await wait(200);
|
||||
const off = await client.setTrayPrefs(true, false);
|
||||
check("backgrounding can be turned back off", off?.closeToTray === false, off);
|
||||
@@ -237,7 +242,7 @@ async function instanceChecks(win) {
|
||||
const { client } = current();
|
||||
check("the running kernel is undisturbed", !!(await client.trayPrefs()));
|
||||
|
||||
const otherHome = path.join(os.tmpdir(), "rx-other-home");
|
||||
const otherHome = fs.mkdtempSync(path.join(os.tmpdir(), "rx-other-home-"));
|
||||
const elsewhere = await launch(otherHome);
|
||||
check("a launch over another home is allowed to run", elsewhere.took === -1, elsewhere.took);
|
||||
|
||||
@@ -344,6 +349,9 @@ app.whenReady().then(async () => {
|
||||
failures.push(String(err && err.message));
|
||||
process.stdout.write(` FAIL ${String(err && err.message)}\n`);
|
||||
}
|
||||
process.stdout.write(failures.length ? `\n${failures.length} failed\n` : "\nall checks passed\n");
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
app.once("will-quit", () => {
|
||||
process.stdout.write(failures.length ? `\n${failures.length} failed\n` : "\nall checks passed\n");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
});
|
||||
app.quit();
|
||||
});
|
||||
@@ -58,6 +58,7 @@ node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲
|
||||
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
|
||||
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
|
||||
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
|
||||
node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到没挂载的那条、同一行里的第 n 处落在眼前
|
||||
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
|
||||
```
|
||||
|
||||
|
||||
@@ -126,6 +126,67 @@ for (const width of [1440, 640, 420]) {
|
||||
check(`${width}px:回执不超出回答栏宽`, row.dRight <= 1, `超出 ${row.dRight}px`);
|
||||
}
|
||||
|
||||
// In the reading measure the transcript and the composer are one column: every
|
||||
// card's edges are the composer's. The full measure widens the transcript only.
|
||||
const col = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: "zh-CN", colorScheme: "dark" });
|
||||
col.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await col.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await col.waitForSelector(".compose");
|
||||
await col.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "text", text: "先读文件再构建。" });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "col-read", name: "read_file", args: '{"path":"a.ts"}', readOnly: true } });
|
||||
window.__feed({ kind: "tool_result", tool: { id: "col-read", name: "read_file", args: '{"path":"a.ts"}', output: "1→ok", readOnly: true } });
|
||||
window.__feed({ kind: "ask_request", ask: { id: "col-ask", questions: [
|
||||
{ id: "q1", header: "方案", prompt: "选择一种方案", options: [{ label: "方案 A" }, { label: "方案 B" }] },
|
||||
] } });
|
||||
});
|
||||
await col.waitForSelector('.call[data-k="ask"]');
|
||||
const edges = () => col.evaluate(() => {
|
||||
const r = (el) => { const b = el.getBoundingClientRect(); return { l: Math.round(b.left), r: Math.round(b.right) }; };
|
||||
const all = (sel) => [...document.querySelectorAll(sel)].map(r);
|
||||
return {
|
||||
compose: r(document.querySelector(".compose")),
|
||||
flow: r(document.querySelector(".flow")),
|
||||
boxed: [...all(".flow .activity-group"), ...all('.flow .call[data-k="ask"]')],
|
||||
replies: all('.flow .call[data-k="say"] > .c'),
|
||||
bubbles: all('.flow .call[data-k="me"] .out .txt'),
|
||||
};
|
||||
});
|
||||
const setMeasure = (full) => col.evaluate((f) => {
|
||||
if (f) document.documentElement.dataset.measure = "full";
|
||||
else delete document.documentElement.dataset.measure;
|
||||
}, full);
|
||||
const composeAt = {};
|
||||
for (const width of [1440, 1000]) {
|
||||
await col.setViewportSize({ width, height: 900 });
|
||||
await setMeasure(false);
|
||||
await col.waitForTimeout(300);
|
||||
const e = await edges();
|
||||
composeAt[width] = e.compose;
|
||||
const off = (xs, sides) => xs.filter((x) => sides.some((s) => Math.abs(x[s] - e.compose[s]) > 1));
|
||||
check(`${width}px 适宜阅读:找到了要比的卡片`, e.boxed.length >= 2 && e.replies.length >= 1 && e.bubbles.length >= 1,
|
||||
`${e.boxed.length} 张卡 / ${e.replies.length} 段回复 / ${e.bubbles.length} 个气泡`);
|
||||
check(`${width}px 适宜阅读:卡片左右边与输入框对齐`, off(e.boxed, ["l", "r"]).length === 0,
|
||||
`输入框 ${e.compose.l}–${e.compose.r},卡片 ${JSON.stringify(off(e.boxed, ["l", "r"]))}`);
|
||||
check(`${width}px 适宜阅读:回复与输入框同宽`, off(e.replies, ["l", "r"]).length === 0,
|
||||
`输入框 ${e.compose.l}–${e.compose.r},回复 ${JSON.stringify(off(e.replies, ["l", "r"]))}`);
|
||||
check(`${width}px 适宜阅读:用户气泡右边与输入框对齐`, off(e.bubbles, ["r"]).length === 0,
|
||||
`输入框右 ${e.compose.r},气泡 ${JSON.stringify(off(e.bubbles, ["r"]))}`);
|
||||
}
|
||||
await col.setViewportSize({ width: 1440, height: 900 });
|
||||
await setMeasure(true);
|
||||
await col.waitForTimeout(300);
|
||||
const full = await edges();
|
||||
await setMeasure(false);
|
||||
check("全宽:输入框宽度不跟着变", full.compose.l === composeAt[1440].l && full.compose.r === composeAt[1440].r,
|
||||
`适宜阅读 ${composeAt[1440].l}–${composeAt[1440].r},全宽 ${full.compose.l}–${full.compose.r}`);
|
||||
check("全宽:转录比输入框宽", full.flow.r - full.flow.l > full.compose.r - full.compose.l + 100,
|
||||
`转录 ${full.flow.r - full.flow.l}px,输入框 ${full.compose.r - full.compose.l}px`);
|
||||
check("全宽:卡片跟着转录到边", full.boxed.every((x) => Math.abs(x.l - full.flow.l) <= 1 && Math.abs(x.r - full.flow.r) <= 1),
|
||||
`转录 ${full.flow.l}–${full.flow.r},卡片 ${JSON.stringify(full.boxed)}`);
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
|
||||
|
||||
@@ -98,6 +98,54 @@ await page.waitForSelector(".slashmenu", { state: "detached" });
|
||||
await page.waitForSelector('.studio-model-control > button[aria-expanded="true"]');
|
||||
check("补全与模型菜单互斥", await page.locator(".menu:not([hidden])").count() === 1);
|
||||
|
||||
// The toolbar shares its line with the send cluster, whose width depends on
|
||||
// whether a turn runs. Every column width has to hold both: controls inside the
|
||||
// composer, none under another, and the model name still readable.
|
||||
const LONG_MODEL = "mimo-v2.6-pro-reasoning-preview";
|
||||
const toolbar = () => page.evaluate((name) => {
|
||||
const nm = document.querySelector(".studio-model-control .nm");
|
||||
if (nm) nm.textContent = name;
|
||||
const edge = document.querySelector(".compose").getBoundingClientRect();
|
||||
const shown = [...document.querySelectorAll(".compose .row button")]
|
||||
.filter((el) => el.getClientRects().length && el.checkVisibility({ visibilityProperty: true }));
|
||||
const id = (el) => el.getAttribute("data-action") || el.getAttribute("aria-label") || el.className.toString();
|
||||
const boxes = shown.map((el) => ({ el, id: id(el), r: el.getBoundingClientRect() }));
|
||||
const outside = boxes.filter(({ r }) => r.left < edge.left - 0.5 || r.right > edge.right + 0.5 || r.bottom > edge.bottom + 0.5).map((b) => b.id);
|
||||
const overlaps = [];
|
||||
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
|
||||
const a = boxes[i], b = boxes[j];
|
||||
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
||||
if (a.r.left < b.r.right - 1 && a.r.right > b.r.left + 1 && a.r.top < b.r.bottom - 1 && a.r.bottom > b.r.top + 1) overlaps.push(`${a.id} × ${b.id}`);
|
||||
}
|
||||
return {
|
||||
pane: Math.round(document.querySelector(".pane").getBoundingClientRect().width),
|
||||
outside, overlaps, name: nm ? Math.round(nm.getBoundingClientRect().width) : 0,
|
||||
};
|
||||
}, LONG_MODEL);
|
||||
|
||||
const swept = new Set();
|
||||
for (const state of ["idle", "running"]) {
|
||||
await page.setViewportSize({ width: 1010, height: 800 });
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
if (state === "running") {
|
||||
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
|
||||
await page.waitForSelector('[data-action="session.stop"]');
|
||||
}
|
||||
const bad = [];
|
||||
for (let width = 340; width <= 760; width += 10) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await frame();
|
||||
const g = await toolbar();
|
||||
swept.add(g.pane);
|
||||
if (g.outside.length) bad.push(`${g.pane}px 越界: ${g.outside.join(" / ")}`);
|
||||
if (g.overlaps.length) bad.push(`${g.pane}px 重叠: ${g.overlaps.join(" / ")}`);
|
||||
if (g.name < 40) bad.push(`${g.pane}px 模型名只剩 ${g.name}px`);
|
||||
}
|
||||
check(`${state}:各宽度下控件都在编辑器内、互不覆盖、模型名可读`, bad.length === 0, bad.slice(0, 4).join(";"));
|
||||
}
|
||||
check("扫到的对话列覆盖 360–700px", [...swept].some((w) => w <= 360) && [...swept].some((w) => w >= 700), [...swept].sort((a, b) => a - b).slice(0, 1).concat([...swept].sort((a, b) => b - a).slice(0, 1)).join("–"));
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// 对话内查找:快捷键打开、计数、跳到没挂载的那一条、当前处真的落在眼前。
|
||||
//
|
||||
// 记录只挂几十张卡,浏览器自带的查找看不见屏幕外的消息;这里要证的是应用内
|
||||
// 的查找能把一条从未挂载过的消息带到眼前,并且高亮落在读者看得见的地方 ——
|
||||
// jsdom 没有布局,这几条只能在真浏览器里问。
|
||||
import { chromium } from "playwright";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.SHOTS ?? fileURLToPath(new URL("shots", import.meta.url));
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
page.on("console", (m) => m.type() === "error" && fails.push("控制台错误: " + m.text()));
|
||||
|
||||
// 当前处那一段高亮,量它在滚动容器里的位置;没有就说没有。
|
||||
const current = () =>
|
||||
page.evaluate(() => {
|
||||
const now = CSS.highlights.get("rx-find-now");
|
||||
const range = now && [...now][0];
|
||||
const flow = document.querySelector('[data-pane="flow"]');
|
||||
if (!range || !flow) return null;
|
||||
const box = range.getBoundingClientRect();
|
||||
const view = flow.getBoundingClientRect();
|
||||
// 落在视口里还不够:长输出在自己的框里滚,框外的那一截同样看不见。
|
||||
const host = range.startContainer.parentElement;
|
||||
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
|
||||
const seen = !!hit && (hit === host || host.contains(hit));
|
||||
return { text: range.toString(), top: box.top, bottom: box.bottom, h: box.height, viewTop: view.top, viewBottom: view.bottom, seen };
|
||||
});
|
||||
const inView = (c) => !!c && c.h > 0 && c.seen && c.top >= c.viewTop + 40 && c.bottom <= c.viewBottom;
|
||||
// 落地要等块挂上、位置稳住,给它一段时间,而不是赌一个固定的等待。
|
||||
async function settled() {
|
||||
let c = null;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await page.waitForTimeout(50);
|
||||
c = await current();
|
||||
if (inView(c)) return c;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
async function suite(scheme) {
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.goto(`${PAGE}?turns=200`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate((s) => document.documentElement.setAttribute("data-theme", s), scheme);
|
||||
const tag = scheme;
|
||||
|
||||
const mountedFirst = await page.locator('[data-item] :text("第 3 个问题")').count();
|
||||
check(`${tag} 第 3 问原本没挂载`, mountedFirst === 0);
|
||||
|
||||
// 焦点在输入框里:它没有自己的查找,快捷键要能穿过它。
|
||||
await page.locator("textarea").first().focus();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await page.waitForTimeout(250);
|
||||
const box = page.locator(".tfind input");
|
||||
check(`${tag} 快捷键从输入框里打开查找条`, (await box.count()) === 1);
|
||||
check(`${tag} 焦点在查找框`, await box.evaluate((el) => el === document.activeElement).catch(() => false));
|
||||
await page.screenshot({ path: `${SHOTS}/${tag}-1-打开查找条.png` });
|
||||
|
||||
await box.pressSequentially("第 3 个问题", { delay: 20 });
|
||||
const c1 = await settled();
|
||||
const n = await page.locator(".tfind .n").innerText();
|
||||
check(`${tag} 计数`, n === "1 / 1", n);
|
||||
check(`${tag} 屏幕外那条被带到眼前`, inView(c1), JSON.stringify(c1));
|
||||
check(`${tag} 高亮的是查的字`, c1?.text === "第 3 个问题", c1?.text);
|
||||
await page.screenshot({ path: `${SHOTS}/${tag}-2-跳到屏幕外的匹配.png` });
|
||||
|
||||
// 一条工具输出里有二十处:每按一次 Enter,当前处要往下走,而且留在眼前。
|
||||
await box.fill("第 120 次输出");
|
||||
await page.waitForTimeout(700);
|
||||
const total = await page.locator(".tfind .n").innerText();
|
||||
check(`${tag} 同一行里的多处都算进去`, total === "1 / 20", total);
|
||||
const tops = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (i > 0) await box.press("Enter");
|
||||
const c = await settled();
|
||||
check(`${tag} 第 ${i + 1} 处在眼前`, inView(c), JSON.stringify(c));
|
||||
tops.push(c?.top ?? NaN);
|
||||
}
|
||||
const moved = new Set(tops.map((t) => Math.round(t))).size;
|
||||
check(`${tag} 当前处随步进移动`, moved > 1, `${moved} 个不同位置`);
|
||||
await page.screenshot({ path: `${SHOTS}/${tag}-3-同一行里的第20处.png` });
|
||||
|
||||
await box.press("Shift+Enter");
|
||||
await page.waitForTimeout(400);
|
||||
check(`${tag} Shift+Enter 往回`, (await page.locator(".tfind .n").innerText()) === "19 / 20");
|
||||
await box.press("ArrowUp");
|
||||
await page.waitForTimeout(400);
|
||||
check(`${tag} 上箭头往回`, (await page.locator(".tfind .n").innerText()) === "18 / 20");
|
||||
|
||||
await box.press("Escape");
|
||||
await page.waitForTimeout(200);
|
||||
check(`${tag} Esc 关掉`, (await page.locator(".tfind").count()) === 0);
|
||||
check(`${tag} 关掉后高亮清空`, await page.evaluate(() => !CSS.highlights.has("rx-find") && !CSS.highlights.has("rx-find-now")));
|
||||
}
|
||||
|
||||
for (const scheme of ["light", "dark"]) await suite(scheme);
|
||||
|
||||
// 窄屏:没有键盘,入口得是看得见的按钮。
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await page.goto(`${PAGE}?turns=200`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(1200);
|
||||
const button = page.locator(".chrome .find-action");
|
||||
check("窄屏 顶栏有查找按钮", await button.isVisible().catch(() => false));
|
||||
if (await button.isVisible().catch(() => false)) {
|
||||
await button.click();
|
||||
await page.waitForTimeout(250);
|
||||
await page.locator(".tfind input").pressSequentially("第 5 个问题", { delay: 20 });
|
||||
check("窄屏 按钮打开的查找条能跳过去", inView(await settled()));
|
||||
}
|
||||
await page.screenshot({ path: `${SHOTS}/narrow-查找.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log(fails.length ? `\n失败 ${fails.length} 条:\n ${fails.join("\n ")}` : "\n全部通过");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -56,7 +56,7 @@ const undeclared = census.roots.filter((r) => !r.actions.length);
|
||||
// denominator that grows because the instrument was corrected is not the same
|
||||
// event as one that grows because debt was added, and numbers either side of
|
||||
// that change do not compare.
|
||||
const UNDECLARED_CEILING = 219;
|
||||
const UNDECLARED_CEILING = 218;
|
||||
|
||||
/** One file, for holding the discovery rules themselves. */
|
||||
const only = (src: string) => certifyRoots(new Map([["src/ui/X.tsx", src]]), { resolve });
|
||||
|
||||
@@ -91,6 +91,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// header ask the same thing of the kernel, and which grant rides on
|
||||
// data-target, with "all" as the value that names none of them.
|
||||
{ id: "permissions.revoke-session", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "permissions.revoke-remembered", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
|
||||
{ id: "tool.image-open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "tool.image-close", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -150,6 +151,19 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "backup.create", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "backup.restore", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.delete", kind: "destructive", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.preview", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "backup.cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.pick", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.allow", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.apply", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "backup.install-plugin", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.done", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "ask.answer", kind: "interaction", target: "none", proof: "authority-effect" },
|
||||
{ id: "ask.step", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "code.download", kind: "interaction", target: "none", proof: "interaction" },
|
||||
@@ -174,6 +188,35 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "extensions.export", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "extensions.enabled", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "extensions.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "extensions.tab", kind: "view", target: "none", proof: "interaction" },
|
||||
// The community market: reads are views, and the one write is the install
|
||||
// its confirmation page answers with the plan the person just read.
|
||||
{ id: "market.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.kind", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.sort", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.pinned", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.show-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "market.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "market.cancel", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "market.inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.trust", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.confirm-many", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.install", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "market.view-installed", kind: "navigation", target: "none", proof: "interaction" },
|
||||
// Publishing: the draft is on screen only, and the submission is the one
|
||||
// write, spending the account session on the registry.
|
||||
{ id: "market.view", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.signin", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "market.draft", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.publish", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.publish-again", kind: "view", target: "none", proof: "interaction" },
|
||||
// The account's own packages: a preview, an install pinned to that preview's
|
||||
// digest, and sending a private one to review.
|
||||
{ id: "market.own-inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.submit", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.vote", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
// Taking back the innermost open thing: a popover, an inline form. One
|
||||
// intent, reached by pressing away and by Escape.
|
||||
{ id: "layer.dismiss", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -244,6 +287,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "provider.select", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.move", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.rename-start", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.rename", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "roles.model", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "permissions.rule-level", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -276,6 +321,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.pin", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.restart", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.later", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
@@ -336,6 +383,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workbench.folder", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.diff", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
|
||||
{ id: "workbench.menu", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
|
||||
@@ -352,6 +400,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.file", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "tool-approval.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// The folder-trust question and the no-sandbox note beside the composer.
|
||||
{ id: "workspace.trust", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "posture-note.dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "storage.move", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "session.import-legacy", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "network.diagnose", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
@@ -367,6 +418,17 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "sandbox.add-write-root", kind: "kernel-mutation", target: "optional", proof: "authority-effect" },
|
||||
{ id: "sandbox.remove-write-root", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// Read by the running turn at its next round, so no rebuild stands between the
|
||||
// click and the canonical change.
|
||||
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// The stall strip beside the composer. Stopping is the ordinary interrupt and
|
||||
// continuing is an ordinary message; muting and dismissing touch this window.
|
||||
{ id: "stall.stop", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "stall.continue", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "stall.mute", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "stall.dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "provider.remove", kind: "destructive", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.protocol", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.web-search", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EN_METRICS } from "./en_metrics";
|
||||
import { EN_STORAGE } from "./en_storage";
|
||||
import { EN_USAGE } from "./en_usage";
|
||||
import { EN_COMPOSER } from "./en_composer";
|
||||
import { EN_BACKUP } from "./en_backup";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
"工作台": "Workbench",
|
||||
@@ -24,6 +25,7 @@ export const EN: Record<string, string> = {
|
||||
...EN_STORAGE,
|
||||
...EN_USAGE,
|
||||
...EN_COMPOSER,
|
||||
...EN_BACKUP,
|
||||
...EN_KERNEL,
|
||||
...EN_REMOTE,
|
||||
...EN_WINDOW,
|
||||
@@ -491,6 +493,7 @@ export const EN: Record<string, string> = {
|
||||
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
|
||||
"在代码编辑器中打开工作区": "Open the workspace in a code editor",
|
||||
"在系统文件管理器中显示工作区": "Show the workspace in the system file manager",
|
||||
"显示隐藏文件": "Show hidden files",
|
||||
"在访达中显示": "Reveal in Finder",
|
||||
"在文件资源管理器中显示": "Reveal in File Explorer",
|
||||
"在系统文件管理器中显示": "Show in file manager",
|
||||
@@ -679,6 +682,10 @@ export const EN: Record<string, string> = {
|
||||
"本轮有改动无法还原": "Some of this turn's changes cannot be restored",
|
||||
"部分改动不在快照内": "Some of the changes are outside the snapshot",
|
||||
"已还原 {n} 个文件": "{n} files restored",
|
||||
"代码和对话": "Code and conversation",
|
||||
"只还原代码": "Code only",
|
||||
"只回退对话": "Conversation only",
|
||||
"bash 命令所作的修改没有快照": "Changes made by bash commands have no snapshot",
|
||||
|
||||
// ── 开场 ─────────────────────────────────────────────────────────
|
||||
"欢迎使用 Reasonix。": "Welcome to Reasonix.",
|
||||
@@ -696,15 +703,6 @@ export const EN: Record<string, string> = {
|
||||
"无法连接版本目录": "Cannot reach the version index",
|
||||
"已固定": "Pinned",
|
||||
"固定在这里": "Pin here",
|
||||
"清除固定": "Clear the pin",
|
||||
"恢复自动更新": "Resume automatic updates",
|
||||
"可在下方对应行安装,安装完成后会自动重启。": "Install it from the row below; it restarts itself when done.",
|
||||
"回退后固定是有意为之:否则下次更新会将你带回刚离开的版本。":
|
||||
"Pinning after a rollback is deliberate: otherwise the next update would put you back on the version you just left.",
|
||||
"该固定已与实际情况不符,自动更新按未固定处理。":
|
||||
"This pin no longer describes anything real, so automatic updates treat it as unpinned.",
|
||||
"切换版本期间请勿关闭窗口。较新版本写入的会话在旧版本中暂时无法打开,升级回去后即可恢复。":
|
||||
"Do not close the window while versions are switching. A session written by a newer version will not open on an older one until you upgrade again.",
|
||||
|
||||
// ── 账号 ─────────────────────────────────────────────────────────
|
||||
"正在检查登录状态…": "Checking sign-in status…",
|
||||
@@ -882,6 +880,14 @@ export const EN: Record<string, string> = {
|
||||
"{n} 天前": "{n} days ago",
|
||||
"下载中 {got} / {all}": "Downloading {got} / {all}",
|
||||
"下载中 {got}": "Downloading {got}",
|
||||
"下载完整安装包 {got} / {all}": "Downloading the full package {got} / {all}",
|
||||
"下载完整安装包 {got}": "Downloading the full package {got}",
|
||||
"安装目录无法就地替换文件,本次改为下载完整安装包。": "The install folder cannot have its files replaced in place, so this update downloads the full package.",
|
||||
"增量数据下载失败,本次改为下载完整安装包。": "The changed parts could not be downloaded, so this update downloads the full package.",
|
||||
"增量数据下载太慢,本次改为下载完整安装包。": "The changed parts were downloading too slowly, so this update downloads the full package.",
|
||||
"增量数据未通过校验,本次改为下载完整安装包。": "The changed parts failed verification, so this update downloads the full package.",
|
||||
"无法写入增量更新文件,本次改为下载完整安装包。": "The changed parts could not be written to disk, so this update downloads the full package.",
|
||||
"增量更新不可用,本次改为下载完整安装包。": "Updating only the changed parts is unavailable, so this update downloads the full package.",
|
||||
"校验签名…": "Verifying the signature…",
|
||||
"准备安装…": "Preparing to install…",
|
||||
"等待系统授权…": "Waiting for permission…",
|
||||
@@ -911,7 +917,7 @@ export const EN: Record<string, string> = {
|
||||
|
||||
// ── 卡片 ─────────────────────────────────────────────────────────
|
||||
"将工作区与对话回退至该消息之前": "Take the workspace and the conversation back to before this message",
|
||||
"↩ 回到这里": "↩ Back to here",
|
||||
"回到这里": "Back to here",
|
||||
"按技能 {name} 的设定运行的子代理": "A subagent running under the {name} skill's settings",
|
||||
"外部服务 {name} 提供的工具": "A tool provided by the external service {name}",
|
||||
"这次调用解析到的能力目录条目": "The capability catalogue entry this call resolved to",
|
||||
@@ -1194,4 +1200,18 @@ export const EN: Record<string, string> = {
|
||||
"New tools",
|
||||
"agent 可以调用,每次调用照常经过权限确认":
|
||||
"The agent can call them; every call still goes through permission checks",
|
||||
"自上次有进展以来已用约 {n} 倍上下文的输入 token": "About {n}× the context window in input tokens since the last progress",
|
||||
"已连续 {n} 轮没有可观察的进展": "No observable progress for {n} rounds",
|
||||
"共 {n} tokens": "{n} tokens in all",
|
||||
"没有文件改动、检查变化或新读取": "No file changed, no check moved, nothing new read",
|
||||
"任务已暂停:{s}": "Task paused: {s}",
|
||||
"继续(计数清零)": "Continue (resets the count)",
|
||||
"不再提示(本会话)": "Don’t show again (this session)",
|
||||
"任务已按设置暂停:自上次有进展以来的输入已达上下文窗口的 {n} 倍": "Paused by your setting: input since the last progress reached {n}× the context window",
|
||||
"任务已按设置暂停:连续 {n} 轮没有可观察的进展": "Paused by your setting: {n} rounds in a row without observable progress",
|
||||
"模型在重复输出同一段文字": "The model is repeating the same text",
|
||||
"同一段内容被逐字重复": "The same block is being repeated verbatim",
|
||||
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
|
||||
"等待你的输入": "Waiting for you",
|
||||
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// English for the cloud backup section under Settings > Account.
|
||||
export const EN_BACKUP: Record<string, string> = {
|
||||
"云备份": "Cloud backup",
|
||||
"把本机的模型、扩展、记忆等配置加密保存到账号,换机器或重装后按项恢复。":
|
||||
"Encrypt this machine's models, extensions, memory and other setup into your account, and restore it item by item on a new machine or after a reinstall.",
|
||||
"模型与界面设置": "Models and interface",
|
||||
"技能、插件与 MCP": "Skills, plugins and MCP",
|
||||
"长期记忆": "Long-term memory",
|
||||
"Hooks 与状态栏命令": "Hooks and status line command",
|
||||
"API 密钥": "API keys",
|
||||
"供应商、默认模型、预设与界面外观;不含审批模式、权限和沙箱":
|
||||
"Providers, default model, presets and appearance; approval mode, permissions and sandbox are never included",
|
||||
"插件只记录来源与版本,恢复时仍走插件安装确认":
|
||||
"Plugins are recorded by source and version only, and restoring one goes through the usual install confirmation",
|
||||
"用户级指令文件与全局记忆": "User-level instruction files and global memory",
|
||||
"会在本机运行的命令,恢复时逐项确认": "Commands that run on this machine; each one is confirmed on restore",
|
||||
"默认不备份。勾选后密钥会随备份一起加密上传": "Off by default. When ticked, keys are encrypted and uploaded with the backup",
|
||||
"备份在本机用你设置的口令加密后才上传,服务端看不到其中内容。忘记口令就无法恢复。":
|
||||
"A backup is encrypted here with your passphrase before it is uploaded, so the service cannot read it. A forgotten passphrase cannot be recovered.",
|
||||
"备份内容": "What to back up",
|
||||
"备注(可选)": "Label (optional)",
|
||||
"例如:公司笔记本": "e.g. work laptop",
|
||||
"加密口令(至少 {n} 个字符)": "Passphrase (at least {n} characters)",
|
||||
"再输一次": "Repeat it",
|
||||
"两次输入的口令不一致": "The two passphrases differ",
|
||||
"正在加密上传…": "Encrypting and uploading…",
|
||||
"备份到账号": "Back up to account",
|
||||
"已用 {n}/{max} 份": "{n} of {max} used",
|
||||
"已备份。": "Backed up.",
|
||||
"已备份。{n} 个文件过大或无法读取,未包含在内。": "Backed up. {n} files were too large or unreadable and were left out.",
|
||||
"账号里还没有备份。": "No backups in this account yet.",
|
||||
"未命名备份": "Untitled backup",
|
||||
"恢复…": "Restore…",
|
||||
"确认删除": "Confirm delete",
|
||||
"正在读取备份…": "Loading backups…",
|
||||
"本机没有": "Not on this machine",
|
||||
"与本机不同": "Differs from this machine",
|
||||
"与本机相同": "Same as this machine",
|
||||
"需安装": "Needs install",
|
||||
"我已看过这条命令,允许它在本机运行": "I have read this command and allow it to run on this machine",
|
||||
"我确认允许把对话和本机保存的密钥发往这个地址": "I allow conversations and the key stored here to be sent to this address",
|
||||
"我确认用备份里的密钥替换本机已保存的密钥": "I allow the key in the backup to replace the one stored here",
|
||||
"我已读过这份指令文件,允许它引用其中列出的文件": "I have read this instruction file and allow the files it imports",
|
||||
"已恢复 {n} 项。": "Restored {n} items.",
|
||||
"配置已写入,但当前会话未能重新加载:{e}": "The setup was written, but this session could not reload it: {e}",
|
||||
"以下插件需要走正常的安装确认:": "These plugins go through the usual install confirmation:",
|
||||
"安装…": "Install…",
|
||||
"输入备份「{name}」的加密口令,先预览它和本机的差异,不会立即写入。":
|
||||
"Enter the passphrase for “{name}” to preview how it differs from this machine. Nothing is written yet.",
|
||||
"加密口令": "Passphrase",
|
||||
"正在解密…": "Decrypting…",
|
||||
"预览差异": "Preview differences",
|
||||
"这份备份来自 {p}": "This backup comes from {p}",
|
||||
"命令和路径在另一种系统上可能无法运行,这类项目默认不勾选。":
|
||||
"Commands and paths may not work on another system, so those items start unticked.",
|
||||
"本机现为:": "Here now: ",
|
||||
"本机不存在:{p}": "Missing here: {p}",
|
||||
"来自另一种系统,可能无法运行": "From another system; it may not run here",
|
||||
"备份时有 {n} 个文件过大或无法读取,未包含在内。": "{n} files were too large or unreadable at backup time and are not included.",
|
||||
"正在恢复…": "Restoring…",
|
||||
"恢复所选 {n} 项": "Restore {n} selected",
|
||||
"还有 {n} 项需要逐项确认": "{n} items still need confirmation",
|
||||
};
|
||||
@@ -2,6 +2,14 @@
|
||||
// that remain available while an agent is live.
|
||||
export const EN_COMPOSER: Record<string, string> = {
|
||||
"↓ 回到最新": "↓ Back to latest",
|
||||
"不思考": "No reasoning",
|
||||
"超深入": "Extra deep",
|
||||
"不做推理直接作答,响应最快,适合简单问答": "Answers without reasoning; fastest, for simple questions",
|
||||
"比深入投入更多推理,适合困难的多步问题": "More reasoning than Deep, for hard multi-step problems",
|
||||
"Pro 模式": "Pro mode",
|
||||
"更慢:模型为同一请求做更多推理,消耗的 token 与费用明显更高。仅对当前会话生效。":
|
||||
"Slower: the model does more reasoning for the same request, using noticeably more tokens and money. Applies to this session only.",
|
||||
"费用更高": "Costs more",
|
||||
"停下": "Stop",
|
||||
"发送": "Send",
|
||||
"插话": "Steer",
|
||||
@@ -15,6 +23,16 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"Enter 插话 · Shift+Enter 换行": "Enter to steer · Shift+Enter for a new line",
|
||||
"{n} 字 · {lines} 行": "{n} characters · {lines} lines",
|
||||
"知道了": "Got it",
|
||||
"是否信任此文件夹": "Trust this folder",
|
||||
"是否信任此文件夹?": "Trust this folder?",
|
||||
"信任后,在此文件夹里的编辑和命令不再逐条询问(桌面端和终端都是)。系统沙盒把命令的写入限制在此文件夹、你额外允许写入的目录、临时目录和工具链缓存内;网络与拒绝规则照常生效。":
|
||||
"Once trusted, edits and commands in this folder run without asking each time, here and in the terminal. The OS sandbox limits what commands write to this folder, the directories you allowed writes to, temp and toolchain caches; network and deny rules still apply.",
|
||||
"信任此文件夹": "Trust this folder",
|
||||
"暂不信任": "Not now",
|
||||
"新会话默认每次询问": "New sessions ask first",
|
||||
"当前没有生效的系统沙盒(Windows 暂不支持,或已在设置中关闭),命令的写入不受限制,所以编辑和命令执行前都会先问你。切换到自动批准后,之后所有文件夹的新会话都会使用它。":
|
||||
"No OS sandbox is in effect (Windows has none yet, or it is turned off in Settings), so nothing limits what commands write - edits and commands ask you first. Switching to auto-approve applies to new sessions in every folder.",
|
||||
"切换到自动批准": "Switch to auto-approve",
|
||||
"本轮执行策略": "How this turn runs",
|
||||
"工作策略": "Work strategy",
|
||||
"当前分支 · {branch}": "Current branch · {branch}",
|
||||
|
||||
@@ -46,6 +46,30 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"未能导出该插件:{detail}": "That plugin could not be exported: {detail}",
|
||||
"无法解析本次安装请求:{detail}": "That install request could not be read: {detail}",
|
||||
"安装失败:{detail}": "It could not be installed: {detail}",
|
||||
"这不是有效的社区包名称": "That is not a community package name",
|
||||
"社区市场中没有这个已审核的包": "The community market has no approved package by that name",
|
||||
"无法连接社区市场:{detail}": "The community market could not be reached: {detail}",
|
||||
"社区市场返回了无法识别的内容": "The community market answered with something unrecognisable",
|
||||
"社区市场暂不支持只列出已固定内容的包": "The community market cannot list only pinned packages yet",
|
||||
"该包的审核版本没有固定内容,需要信任发布者后才能安装": "The reviewed version is not pinned to its content; trust the publisher to install it",
|
||||
"该包的审核版本指向本机或不安全的来源,不从市场安装": "The reviewed version points at a local or unsafe source, which the market does not install",
|
||||
"审核版本已更新,请重新查看后再安装": "A newer version has been approved - look at it again before installing",
|
||||
"来源内容已与审核版本不同,已拒绝安装": "The source no longer holds the reviewed content, so nothing was installed",
|
||||
"该来源的内容无法固定,已拒绝安装": "This source cannot be pinned by content, so nothing was installed",
|
||||
"来源内容在确认后发生了变化,请重新查看": "The source changed after you confirmed - look at it again",
|
||||
"这个标为主题的包还会安装主题以外的内容,已拒绝安装": "This package is listed as a theme but would install more than themes, so nothing was installed",
|
||||
"登录账号后才能发布或评价": "Sign in to publish or vote",
|
||||
"请先在 id.reasonix.io 验证账号邮箱,再发布或评价": "Verify your account email at id.reasonix.io before publishing or voting",
|
||||
"这个名称已被其他发布者使用,请换一个名称": "That name belongs to another publisher - pick another one",
|
||||
"这个版本号已经发布过,请换一个版本号": "That version is already published - use another version",
|
||||
"请求太频繁,请稍等一分钟再试": "Too many requests - wait a minute and try again",
|
||||
"这个来源审核通过后也无法从市场安装:插件和主题要指向固定到提交的 GitHub 目录(tree/40 位提交号),技能要用 https 地址,MCP 用 https 地址或 npm 包名": "The market could not install this source even once approved: plugins and themes must point at a GitHub directory pinned to a commit (tree/<40-character commit>), skills at an https address, MCP servers at an https address or an npm package name",
|
||||
"社区市场拒绝了这次提交:{detail}": "The community market refused the submission: {detail}",
|
||||
"你的发布里没有这个包": "None of your packages has that name",
|
||||
"只有私有的包才能提交审核": "Only a private package can be submitted for review",
|
||||
"未审核的版本要先预览,再按预览时的内容摘要安装": "Preview an unreviewed version first; it installs only against that preview's content digest",
|
||||
"不能评价自己发布的包": "You cannot vote on a package you published",
|
||||
"评价只能是赞、踩或撤回": "A vote is up, down or withdrawn",
|
||||
"无法解析安装器的响应:{detail}": "The installer's own answer could not be read: {detail}",
|
||||
"无法读取该主题:{detail}": "That theme could not be read: {detail}",
|
||||
"这不是可安装的主题:{detail}": "That is not an installable theme: {detail}",
|
||||
@@ -90,6 +114,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"{provider} names no reasoning-effort levels. To give it some, set reasoning_protocol or supported_efforts in its config block",
|
||||
"{provider} 不提供「{level}」档位,可选值为:{levels}": "{provider} has no \"{level}\" level. Available: {levels}",
|
||||
"无法识别当前使用的来源,请先切换一次模型": "Cannot tell which source is in use — switch models once and try again",
|
||||
"当前模型 {model} 不支持「{mode}」模式": "The current model {model} does not support the “{mode}” mode",
|
||||
"任务正在运行,请先停止再切换工作区": "A task is running — stop it before changing the workspace",
|
||||
"任务正在运行,请先停止再重载扩展": "A task is running — stop it before reloading extensions",
|
||||
"该来源正在使用中,请先切换模型再删除": "This source is in use — switch models before deleting it",
|
||||
@@ -101,6 +126,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"无法识别「{kind}」这种接入方式": "“{kind}” is not a protocol this knows",
|
||||
"请至少选择一个模型": "Pick at least one model",
|
||||
"默认模型「{model}」不在已选择的模型中": "The default model “{model}” is not among the ones you picked",
|
||||
"显示名称最多 {max} 个字符": "A display name can be at most {max} characters",
|
||||
"显示名称不能包含换行或其他控制字符": "A display name cannot contain line breaks or other control characters",
|
||||
"该协议不发送思考参数,启用后不会生效": "This protocol never sends thinking parameters — turning it on does nothing",
|
||||
"该协议在轮次之间不保留状态,无需选择续接方式": "This protocol keeps no state between turns, so there is nothing to choose",
|
||||
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
|
||||
@@ -185,6 +212,22 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"没有已配置的模型匹配「{model}」": "No configured model matches “{model}”",
|
||||
"这台服务器未开放 shell 设置": "This server does not open shell settings",
|
||||
"这台服务器未开放账号登录": "This server does not open account sign-in",
|
||||
"登录账号后才能使用云备份": "Sign in to use cloud backups",
|
||||
"暂时连不上备份服务,稍后再试:{detail}": "The backup service could not be reached; try again later: {detail}",
|
||||
"备注最多 {max} 个字": "A label can be at most {max} characters",
|
||||
"读取本机配置时出错:{detail}": "Reading this machine's setup failed: {detail}",
|
||||
"无法和本机配置比对:{detail}": "Could not compare with this machine's setup: {detail}",
|
||||
"加密口令至少 {min} 个字符": "The passphrase needs at least {min} characters",
|
||||
"至少选择一类要备份的内容": "Choose at least one kind of content to back up",
|
||||
"口令不对,或这份备份已损坏": "Wrong passphrase, or this backup is damaged",
|
||||
"这份备份来自更新的版本,请先升级 Studio": "This backup comes from a newer version; update Studio first",
|
||||
"这份备份的内容无法识别": "The contents of this backup could not be read",
|
||||
"备份超出了大小上限": "The backup is over the size limit",
|
||||
"预览已失效,请重新打开这份备份": "The preview has expired; open the backup again",
|
||||
"所选的项目不在这份备份里": "A selected item is not in this backup",
|
||||
"这份备份已不存在": "That backup no longer exists",
|
||||
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
|
||||
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
|
||||
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
|
||||
"不存在该预设": "There is no such preset",
|
||||
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
|
||||
@@ -196,6 +239,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"最大输出 Token 不能是负数": "The maximum output tokens cannot be negative",
|
||||
"无法识别「{protocol}」这种思考协议": "Unrecognised reasoning protocol “{protocol}”",
|
||||
"默认档位「{level}」不在填写的档位里": "The default level “{level}” is not one of the levels listed",
|
||||
"{model} 的默认档位「{level}」不在为它选的档位里": "The default level “{level}” for {model} is not one of the levels picked for it",
|
||||
"{model} 不在已启用的模型里,不能为它单独设置档位": "{model} is not one of the enabled models, so it cannot have levels of its own",
|
||||
"这个模型上的对话正在运行,停止后再删除": "A conversation on this model is running. Stop it before removing the model",
|
||||
"该会话正在运行,停止后再删除": "This conversation is running. Stop it before deleting it",
|
||||
"这个文件夹里有正在运行的对话,停止后再移除": "A conversation in this folder is running. Stop it before removing the folder",
|
||||
@@ -223,11 +268,15 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"已有一个版本切换正在进行,请等待其完成后重试":
|
||||
"A version change is already running — wait for it to finish and try again",
|
||||
"本次版本切换未能启动:{detail}": "This version change did not start: {detail}",
|
||||
"有 {n} 项任务正在运行,重启会中断它们": "{n} tasks are running, and a restart would stop them",
|
||||
"没有已下载好的版本可供重启安装,请重新点击安装": "No downloaded version is waiting for a restart; choose Install again",
|
||||
"本次启动未能记录为健康状态:{detail}": "This launch could not be recorded as healthy: {detail}",
|
||||
"该请求未发往 Studio 监听的地址,已被拒绝": "That request was not addressed to the address Studio listens on — refused",
|
||||
"该页面不属于 Studio,无法对其操作": "That page is not Studio itself, so it may not drive it",
|
||||
"缺少本次启动的凭据,请重新打开 Studio 后重试": "This launch's credential is missing — reopen Studio and try again",
|
||||
"本机通道未建立,请重新打开 Studio 后重试": "The local channel was never established — reopen Studio and try again",
|
||||
"该服务未开启认证,只接受发往本机或其监听地址的请求": "This server runs without authentication, so it only accepts requests addressed to this machine or its listening address",
|
||||
"该服务未开启认证,修改与审批需要本次启动的令牌,请用启动时打印的链接打开": "This server runs without authentication, so changes and approvals need this launch's token — open it with the link printed at startup",
|
||||
"文件保存请求格式不正确": "The file save request is malformed",
|
||||
"文件已在打开后被其他操作修改,请重新载入": "The file changed after it was opened; reload it and try again",
|
||||
"找不到该文件,它可能已被移动或删除": "The file was not found; it may have been moved or deleted",
|
||||
@@ -236,4 +285,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹": "The target is outside the workspace write scope. This is the file tools' write scope, not an OS sandbox; to allow the write, add the target folder under Settings → Sandbox → Also writable",
|
||||
"无法读取工作区文件列表": "The workspace file list could not be read",
|
||||
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
|
||||
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
|
||||
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
|
||||
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
|
||||
"该数值超出允许范围,未做任何修改:{detail}": "That value is out of range; nothing was changed: {detail}",
|
||||
};
|
||||
@@ -133,6 +133,12 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"连接远程 Studio 超时,请检查电脑是否在线。": "Connecting to Remote Studio timed out. Check that the computer is online.",
|
||||
"远程中转服务暂时不可用,请稍后重试。": "The remote relay is temporarily unavailable. Try again shortly.",
|
||||
"远程 Studio 身份校验失败,请停止连接并检查设备。": "Remote Studio identity verification failed. Stop and check the device.",
|
||||
"需要重新登录": "Sign in again",
|
||||
"为了安全,远程控制在登录满 24 小时或退出登录后需要重新登录。": "For security, remote control asks you to sign in again 24 hours after signing in, or after you sign out.",
|
||||
"重新登录": "Sign in",
|
||||
"远程控制需要重新登录。": "Remote control needs you to sign in again.",
|
||||
"这台电脑已从账号中移除。": "This computer was removed from your account.",
|
||||
"连接中断,这次操作可能没有完成,请刷新后确认。": "The connection dropped; this action may not have finished. Refresh to check.",
|
||||
"互联网远程": "Internet remote access",
|
||||
"互联网连接二维码": "Internet connection QR code",
|
||||
"手机扫码 · 不在同一网络也能连接": "Scan with your phone · works away from this network",
|
||||
|
||||
@@ -27,6 +27,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"搜索已添加的服务": "Search added services",
|
||||
"上移 {name}(Alt+上方向键)": "Move {name} up (Alt+Up Arrow)",
|
||||
"下移 {name}(Alt+下方向键)": "Move {name} down (Alt+Down Arrow)",
|
||||
"重命名 {name}(F2)": "Rename {name} (F2)",
|
||||
"重命名 {name}": "Rename {name}",
|
||||
"回车保存,Esc 取消;留空恢复为「{name}」": "Enter saves, Esc cancels; leave it empty to go back to “{name}”",
|
||||
"无法保存服务顺序。": "Could not save the service order.",
|
||||
"没有匹配的服务。": "No matching service.",
|
||||
"添加一个模型服务后,在这里查看和修改它。": "Add a model service to see and change it here.",
|
||||
@@ -128,6 +131,13 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"The setting above decides whether you are asked; this one decides what is never allowed and what never needs asking. Changes rebuild the runtime and cannot be made while a task is running.",
|
||||
"无法读取权限配置。": "Cannot read the permission config.",
|
||||
"当前项目自带权限配置": "This project ships its own permissions",
|
||||
"当前项目记住的放行规则": "Remembered allow rules for this project",
|
||||
"已记住的权限规则无法读取": "Could not read remembered permission rules",
|
||||
"请检查或修复用户目录中的授权记录文件。": "Check or repair the grant record in your user directory.",
|
||||
"请选择要收回的授权规则": "Select an approval rule to revoke",
|
||||
"任务正在运行,请先停止再收回已记住的授权": "Stop the running task before revoking a remembered approval",
|
||||
"当前会话无法重建,授权未更改": "This session cannot rebuild; the approval was not changed",
|
||||
"无法更新当前项目的授权记录,请检查用户目录中的授权文件": "Could not update this project's approvals; check the grant record in your user directory",
|
||||
"{path} 中同样声明了 permissions,实际生效的是该文件。此处的修改会被保存,但需待其不再声明后才会生效。":
|
||||
"{path} declares permissions too, and that is what is in force. Edits here are saved, but only take effect once it stops declaring them.",
|
||||
"禁止访问 .env": "Keep .env off limits",
|
||||
@@ -257,6 +267,22 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"第一个档位": "The first level",
|
||||
"推理强度选「自动」时使用的档位。": "The level used when reasoning effort is set to Auto.",
|
||||
"{n} 个档位": "{n} levels",
|
||||
"{n} 个模型单独设置": "{n} models with their own levels",
|
||||
"按模型设置档位": "Levels per model",
|
||||
"同一接入下有多家厂商的模型时,可为单个模型指定档位;「继承接入设置」沿用上面的档位。模型自己的档位优先。":
|
||||
"When one connection serves several vendors' models, give a model levels of its own. Inherit uses the levels above; a model's own levels win.",
|
||||
"{model} 的推理档位": "Reasoning levels for {model}",
|
||||
"继承接入设置": "Inherit from connection",
|
||||
"单独设置": "Its own levels",
|
||||
"该模型的思考参数不使用自定义档位。": "This model's thinking parameter does not use custom levels.",
|
||||
"继承:保存后按接入设置确定": "Inherits: settled by the connection once saved",
|
||||
"继承:{levels}": "Inherits: {levels}",
|
||||
"继承:端点未声明档位": "Inherits: the endpoint declares no levels",
|
||||
"其他档位": "Other level",
|
||||
"{model} 的其他档位": "Another level for {model}",
|
||||
"{model} 的默认档位": "Default level for {model}",
|
||||
"第一个档位({level})": "The first level ({level})",
|
||||
"未选档位时按继承处理。": "With no level picked, the model inherits.",
|
||||
"{door} 不提供 {model},请在下方选择该来源支持的模型。": "{door} does not offer {model}. Choose one it does provide below.",
|
||||
"端点控制思考深度的方式。此项无法自动探测:中转站转发的是第三方模型,只有你知道其后端。选择后才能调整推理强度,选择错误会导致请求被端点拒绝。":
|
||||
"How this endpoint expresses thinking depth. It cannot be probed: a relay forwards someone else's model, and only you know what is behind it. Choosing one enables the reasoning level; choosing wrong makes the endpoint reject the request.",
|
||||
@@ -328,8 +354,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"已登录": "Signed in",
|
||||
"未登录": "Not signed in",
|
||||
"版本": "Version",
|
||||
"当前安装的版本、可用更新,以及出现问题时如何回退。回退后将固定在所选版本,不会被自动更新覆盖。":
|
||||
"Which version is installed, whether an update is available, and how to go back if something breaks. A rollback pins the version you chose and is not undone by an automatic update.",
|
||||
"当前安装的版本、可用更新,以及出现问题时如何回退。更新下载好后由你决定何时重启;回退后会固定在所选版本,不再提示新版本。":
|
||||
"The installed version, available updates, and how to go back if something breaks. A downloaded update waits for you to choose when to restart; after a rollback the chosen version is pinned and newer ones stop being offered.",
|
||||
"记忆": "Memory",
|
||||
"agent 自动记录的内容:未经配置,但会据此执行。此处按触发时机分组,并标出上一轮实际使用的条目。":
|
||||
"What the agent recorded on its own: you never configured it, but it acts on it. Grouped here by when each entry is recalled, with the ones actually used last turn marked.",
|
||||
@@ -376,6 +402,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"确认(还有 {n} 个没答)": "Confirm ({n} left)",
|
||||
"确认": "Confirm",
|
||||
"先不选择,直接回复": "Skip the choice and reply instead",
|
||||
"需要你决定": "Need your decision",
|
||||
"需要你补充信息": "Need more information from you",
|
||||
"MCP 服务器 {name} 请求信息": "MCP server {name} is asking for information",
|
||||
"这是外部服务器的请求,不是 agent 的提问。请勿填写密码、密钥等敏感信息。": "This request comes from an external server, not the agent. Do not enter passwords, keys or other secrets.",
|
||||
"上次填写未通过:{note}": "The last answer was not accepted: {note}",
|
||||
@@ -452,11 +480,44 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"自动触发 · 输入到达 {n}": "Automatic · input reached {n}",
|
||||
"移除中…": "Removing…",
|
||||
"{err} —— 本地功能不受影响,请稍后重试。": "{err} — nothing local is affected; try again later.",
|
||||
"已固定在 {v},不会自动更新": "Pinned to {v}; updates will not move it",
|
||||
"固定版本为 {pinned},当前运行的是 {current}": "Pinned to {pinned}; what is running is {current}",
|
||||
"有新版本 {v}": "{v} is out",
|
||||
"切换到 {v} 失败": "Switching to {v} failed",
|
||||
"{err} —— 当前版本未被改动,可以重试。": "{err} — the running version was not touched; you can try again.",
|
||||
"已固定在 {v},不再提示新版本": "Pinned to {v}; newer versions are not offered",
|
||||
"回退时会固定在所选版本,免得新版本提示把你带回刚离开的版本。": "A rollback pins the version you chose, so an update prompt does not take you back to the one you left.",
|
||||
"取消固定": "Unpin",
|
||||
"这个固定没有作用,下次启动时会自动清除。": "This pin holds nothing and is cleared at the next launch.",
|
||||
"现在清除": "Clear it now",
|
||||
"可在下方对应行安装。下载完成后会先问你,再重启。": "Install it from the row below. Once it has downloaded, Studio asks before restarting.",
|
||||
"有 {n} 项任务正在运行": "{n} tasks are running",
|
||||
"{v} 已下载并通过签名校验": "{v} is downloaded and its signature checked",
|
||||
"现在重启会中断它们。可以等任务结束后再重启。": "Restarting now would stop them. You can restart once they finish.",
|
||||
"重启 Studio 后生效。重启会关闭当前窗口,稍后重启也可以。": "It takes effect when Studio restarts. Restarting closes this window; later works too.",
|
||||
"较新版本写入的会话在旧版本中暂时无法打开,升级回去后即可恢复。": " A session written by a newer version will not open on an older one until you upgrade again.",
|
||||
"仍然重启": "Restart anyway",
|
||||
"立即重启": "Restart now",
|
||||
"稍后": "Later",
|
||||
"下载完整安装包": "Download the full installer",
|
||||
"重启以完成安装": "Restart to finish",
|
||||
"已下载,待重启": "Downloaded, waiting for a restart",
|
||||
"正在安装…": "Installing…",
|
||||
"正在重启到新版本…": "Restarting into the new version…",
|
||||
"安装失败": "Install failed",
|
||||
"无法读取 {v} 的发布信息": "Could not read the release details for {v}",
|
||||
"版本目录暂时连不上,可能是网络或代理问题。当前版本未被改动,稍后重试即可。": "The version index cannot be reached right now, likely a network or proxy problem. The running version was not touched; try again later.",
|
||||
"{v} 下载中断": "Downloading {v} was interrupted",
|
||||
"网络中断或超时。当前版本未被改动,可以重试。": "The connection dropped or timed out. The running version was not touched; you can try again.",
|
||||
"{v} 未通过签名校验": "{v} failed its signature check",
|
||||
"下载到的文件与发布签名不符,已丢弃,没有安装。多为下载损坏或网络被篡改,请重试;反复出现请反馈。": "The downloaded file does not match the release signature, so it was discarded and nothing was installed. This is usually a corrupted download or a tampered network; try again, and report it if it keeps happening.",
|
||||
"无法写入更新文件": "The update could not be saved",
|
||||
"磁盘空间不足,或更新缓存目录不可写。清理空间后重试。": "The disk is full, or the update cache folder cannot be written. Free some space and try again.",
|
||||
"{v} 没有适用于本机的安装包": "{v} has no package for this machine",
|
||||
"这个版本没有为本机的系统发布可自动安装的包,请下载安装包手动安装。": "This version publishes no automatically installable package for this system; download the installer and install it by hand.",
|
||||
"{v} 的安装没能开始": "Installing {v} did not start",
|
||||
"安装程序被系统或安全软件拦下了。当前版本未被改动,可以重试,或下载完整安装包手动安装。": "The system or security software stopped the installer. The running version was not touched; try again, or install the full package by hand.",
|
||||
"上次更新到 {v} 没有生效": "The last update to {v} did not take effect",
|
||||
"重启后运行的仍是 {current}:新版本没能替换已安装的文件,已恢复原版本。常见原因是装在 Program Files 或其他磁盘,或文件被安全软件占用。下载完整安装包安装一次即可,之后的更新会恢复正常。": "After the restart {current} is still running: the new version could not replace the installed files, so the previous one was restored. This usually means Studio is installed under Program Files or on another drive, or security software held a file. Install the full package once and updates work again after that.",
|
||||
"当前版本未被改动,可以重试。": "The running version was not touched; you can try again.",
|
||||
"正在运行": "Running now",
|
||||
"更早的版本": "An earlier version",
|
||||
"更新的版本": "A newer version",
|
||||
@@ -638,4 +699,123 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"加载方式": "Loading",
|
||||
"启动时预先连接,工具每轮随请求发送,模型直接调用,省去搜索往返,但占用上下文。": "Connected at startup; the tools go with every request and the model calls them directly: no search round trip, but they take up context.",
|
||||
"用到时再搜索加载,不占上下文,调用前多一轮往返。": "Searched for and loaded when needed: no context used, one extra round trip before a call.",
|
||||
// 社区市场
|
||||
"社区市场": "Community market",
|
||||
"社区发布、经过审核的技能、插件、MCP 服务与主题。固定了审核内容的版本按审核时的内容安装,未固定的需要你信任发布者;安装前都会列出将写入的全部内容,与粘贴地址安装走同一套确认。": "Skills, plugins, MCP servers and themes published by the community and reviewed. A version pinned to its reviewed content installs exactly that; an unpinned one needs you to trust its publisher. Everything an install would write is listed first - the same confirmation a pasted address goes through.",
|
||||
"已安装": "Installed",
|
||||
"发现": "Discover",
|
||||
"MCP 服务": "MCP servers",
|
||||
"插件": "Plugins",
|
||||
"近期热门": "Trending",
|
||||
"暂无评价": "No votes yet",
|
||||
"好评 {pct}%({n} 票)": "{pct}% positive ({n} votes)",
|
||||
"评价": "Vote",
|
||||
"赞": "Upvote",
|
||||
"踩": "Downvote",
|
||||
"撤回赞": "Withdraw upvote",
|
||||
"撤回踩": "Withdraw downvote",
|
||||
"登录后评价": "Sign in to vote",
|
||||
"验证账号邮箱后才能评价": "Verify your account email to vote",
|
||||
"在这台设备上不能评价": "Voting is not available on this device",
|
||||
"安装最多": "Most installed",
|
||||
"最新": "Newest",
|
||||
"排序": "Sort",
|
||||
"搜索技能、插件、MCP 服务与主题": "Search skills, plugins, MCP servers and themes",
|
||||
"搜索社区市场": "Search the community market",
|
||||
"无法读取社区市场": "Could not read the community market",
|
||||
"没有找到匹配的包。": "No matching packages.",
|
||||
"没有找到已固定内容的包。可关闭筛选查看全部包。": "No pinned packages found. Turn off the filter to see all packages.",
|
||||
"查看全部包": "See all packages",
|
||||
"只看已固定": "Pinned only",
|
||||
"未固定": "Not pinned",
|
||||
"固定状态未知": "Pin status unknown",
|
||||
"管理员标记可信": "Admin marked trusted",
|
||||
"可更新": "Update available",
|
||||
"{n} 次安装": "{n} installs",
|
||||
"加载更多": "Load more",
|
||||
"返回列表": "Back to list",
|
||||
"查看已安装能力": "View installed capabilities",
|
||||
"{name} {version} 将安装以下内容": "{name} {version} will install the following",
|
||||
"已按内容摘要核对:与审核时固定的版本一致。": "Checked by content digest: identical to the version pinned at review.",
|
||||
"这个来源包含 {n} 个技能,会全部安装": "This source holds {n} skills, and all of them will be installed",
|
||||
"我已看过这 {n} 个技能,全部安装": "I have read these {n} skills - install all of them",
|
||||
"以下项目需要逐项看过再安装": "Read each of these before installing",
|
||||
"以下项目会改变可用能力": "These change what is available",
|
||||
"以下项目仅添加文件": "These only add files",
|
||||
"无法读取 {name}": "Could not read {name}",
|
||||
"发布者": "Publisher",
|
||||
"审核版本": "Reviewed version",
|
||||
"来源": "Source",
|
||||
"固定内容": "Pinned content",
|
||||
"未固定——审核时没有记录内容摘要": "Not pinned - no content digest was recorded at review",
|
||||
"仓库": "Repository",
|
||||
"标签": "Tags",
|
||||
"已安装 {version}": "Installed {version}",
|
||||
"先列出将安装的全部内容,确认后才会写入": "Everything it would install is listed first; nothing is written until you confirm",
|
||||
"审核时没有记录内容摘要,需要信任发布者后安装": "No content digest was recorded at review; trust the publisher to install it",
|
||||
"内容未经审核固定": "Content not pinned by review",
|
||||
"审核时没有记录内容摘要,无法确认来源现在提供的仍是审核过的内容。只在你信任发布者 @{handle} 时安装。": "No content digest was recorded at review, so there is no way to confirm the source still serves what was reviewed. Install only if you trust the publisher @{handle}.",
|
||||
"信任并安装": "Trust and install",
|
||||
"你选择了信任这个发布者": "You chose to trust this publisher",
|
||||
"以下是来源现在提供的内容,可能与审核时不同。确认后内容若有变化,安装会被拒绝。": "This is what the source serves now, which may differ from what was reviewed. If it changes after you confirm, the install is refused.",
|
||||
"查看将安装的内容": "See what it installs",
|
||||
"查看更新内容": "See what the update installs",
|
||||
"MCP 服务只固定了启动方式与连接地址;它启动后自行下载或远端提供的代码不在固定范围内。": "For an MCP server only how it is started and where it connects are pinned; code it downloads or a remote end serves after that is not",
|
||||
"技能不会被原地覆盖:先在「已安装」里移除旧版本,再回来安装": "A skill is not overwritten in place: remove the old version under Installed first, then install this one",
|
||||
// 社区市场:发布
|
||||
"浏览": "Browse",
|
||||
"我的发布": "My packages",
|
||||
"发布": "Publish",
|
||||
"登录后可以在这里发布技能、插件、MCP 服务和主题,并查看审核进度。": "Sign in to publish skills, plugins, MCP servers and themes here, and to follow their review.",
|
||||
"去登录": "Sign in",
|
||||
"已提交 {slug} {version}": "Submitted {slug} {version}",
|
||||
"审核通过后会在社区市场公开;在「我的发布」里可以看到审核进度。": "It goes public in the community market once approved; My packages shows where review stands.",
|
||||
"再发布一个": "Publish another",
|
||||
"查看我的发布": "See my packages",
|
||||
"以 @{handle} 的名义提交,审核通过后公开。只收来源地址,不上传文件。": "Submitted as @{handle} and made public once approved. Only the source address is sent; no files are uploaded.",
|
||||
"主题以插件包发布,包里只能有主题;带技能、钩子或 MCP 服务的包请按插件发布。": "A theme is published as a plugin package that holds only themes; a package with skills, hooks or MCP servers is published as a plugin.",
|
||||
"小写字母、数字、点、下划线、连字符,最多 64 个字符。": "Lowercase letters, digits, dots, underscores and hyphens, up to 64 characters.",
|
||||
"留空时新包为 0.1.0,更新自动加一个补丁号。": "Left empty, a new package starts at 0.1.0 and an update bumps the patch number.",
|
||||
"来源地址": "Source address",
|
||||
"摘要": "Summary",
|
||||
"描述": "Description",
|
||||
"用逗号分隔,最多 8 个": "Comma-separated, up to 8",
|
||||
"没有提交成功": "The submission did not go through",
|
||||
"提交后进入审核队列;审核员会固定审核时的内容,之后只安装那一份。": "It joins the review queue; the reviewer pins the content they reviewed, and only that is ever installed.",
|
||||
"提交中…": "Submitting…",
|
||||
"提交审核": "Submit for review",
|
||||
"无法读取我的发布": "Could not read your packages",
|
||||
"还没有发布过。": "Nothing published yet.",
|
||||
"固定到提交的 GitHub 目录,形如 https://github.com/owner/repo/tree/(40 位提交号)/子目录": "A GitHub directory pinned to a commit, such as https://github.com/owner/repo/tree/(40-character commit)/subdir",
|
||||
"SKILL.md 的 https 地址,或 GitHub 仓库里某个技能的目录。": "An https address of a SKILL.md, or one skill's directory in a GitHub repository.",
|
||||
".mcp.json 或仓库的 https 地址,或 npm 包名。": "An https address of a .mcp.json or a repository, or an npm package name.",
|
||||
"审核中": "In review",
|
||||
"已公开": "Public",
|
||||
"未通过": "Rejected",
|
||||
"已隐藏": "Hidden",
|
||||
"私有": "Private",
|
||||
"已保存 {slug} {version},仅自己可见": "Saved {slug} {version}, visible only to you",
|
||||
"它不会进入审核,也不会在社区市场出现;在「我的发布」里可以安装它,或随时提交审核。": "It skips review and never appears in the community market; install it from My packages, or submit it for review whenever you like.",
|
||||
"仅自己可见:不提交审核,社区市场里只有你的账号能看到并安装": "Only me: skip review; in the community market only your account can see and install it",
|
||||
"保存后只有你能看到;要公开时在「我的发布」里提交审核。": "Only you will see it; submit it for review from My packages to make it public.",
|
||||
"保存为私有": "Save as private",
|
||||
"仅你可见,未提交审核": "visible only to you, not submitted for review",
|
||||
"返回我的发布": "Back to my packages",
|
||||
"无法预览 {name}": "Could not preview {name}",
|
||||
"正在预览将安装的内容…": "Previewing what would be installed…",
|
||||
"未审核 · 仅你可见": "Unreviewed · visible only to you",
|
||||
"这是你自己发布、还没有通过审核的版本。安装会核对与这次预览相同的内容摘要,内容在确认后有变化就拒绝安装。": "This is your own version, not yet approved by review. The install checks it against this preview's content digest and is refused if the content changes after you confirm.",
|
||||
"内容摘要:{digest}": "Content digest: {digest}",
|
||||
"长时间无进展": "Long stretches without progress",
|
||||
"判断只看宿主能观察到的效果,不看命令内容;提示和暂停都不会向模型发送任何内容,也不会拒绝任何调用。": "Judged only from effects the host can observe, never from what a command says. Neither the notice nor the pause sends the model anything or refuses any call.",
|
||||
"长时间无进展时暂停任务": "Pause a task that stops making progress",
|
||||
"默认关闭:只在输入框上方提示,由你决定是否停止。开启后达到下面的条件会暂停任务,已完成的工作都会保留,发一条消息即可继续。": "Off by default: a notice appears above the composer and stopping is your call. When on, a task that meets the conditions below is paused; the work so far is kept and one message continues it.",
|
||||
"连续无进展轮数": "Rounds without progress",
|
||||
"没有改动文件、没有检查或任务步骤变化、没有读到新内容、也没有子代理返回的工具轮次。默认 {n}。": "Tool rounds in a row that changed no file, moved no check or task step, read nothing new and brought back no sub-agent result. Default {n}.",
|
||||
"轮": "rounds",
|
||||
"输入 token 上限": "Input token backstop",
|
||||
"与模型无关的后备:自上次有进展以来的累计输入达到上下文窗口的这个倍数时同样提示。默认 {n} 倍。": "A model-independent backstop: the same notice once the input spent since the last progress reaches this many context windows. Default {n}×.",
|
||||
"倍窗口": "× window",
|
||||
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
|
||||
"无法读取无进展设置。": "Could not read the progress settings.",
|
||||
};
|
||||
@@ -17,6 +17,8 @@ export const EN_STORAGE: Record<string, string> = {
|
||||
"Where the data is written and how much space it uses. Sessions and indexes keep growing while configuration and credentials do not, so only the former can be moved. A move takes effect after a restart.",
|
||||
"无法读取存储占用。": "Cannot read what is on disk.",
|
||||
"正在统计…": "Measuring…",
|
||||
"统计超时": "Measuring timed out",
|
||||
"目录过大,已统计到时间上限,实际占用不小于此数": "This folder is too large to finish measuring in time; the real size is at least this much.",
|
||||
占用: "Space used",
|
||||
原位置仍有残留数据: "Data remains in the previous location",
|
||||
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此本机的壁纸、主题包或更新回滚备份可能显示为缺失。手动将这些目录复制到当前位置即可恢复。":
|
||||
|
||||
@@ -34,7 +34,6 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"前进": "Forward",
|
||||
"重新加载": "Reload",
|
||||
"网址": "Address",
|
||||
"页面被遮住时暂停显示": "The page is hidden while something covers it",
|
||||
"搜索会话 / 项目": "Search sessions or projects",
|
||||
"没有匹配的会话": "Nothing matches",
|
||||
"清空": "Clear",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// function of the error and the installed catalogue.
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { boot, STORAGE } from "./index";
|
||||
import { PROVIDER_EDIT_DISABLED, SAVED_NOT_APPLIED, codes, reason } from "./kernel";
|
||||
import { ACCOUNT_SIGNIN_DISABLED, PROVIDER_EDIT_DISABLED, SAVED_NOT_APPLIED, codes, reason } from "./kernel";
|
||||
import { HttpError } from "../port/port";
|
||||
|
||||
// Pinned, not defaulted: with nothing stored the window follows the machine, so
|
||||
@@ -94,6 +94,10 @@ describe("codes a caller branches on", () => {
|
||||
expect(codes[PROVIDER_EDIT_DISABLED]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("spells the account refusal the same in the constant and the catalogue", () => {
|
||||
expect(codes[ACCOUNT_SIGNIN_DISABLED]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("spells the saved-but-not-applied answers the same in the constant and the catalogue", () => {
|
||||
for (const code of SAVED_NOT_APPLIED) expect(codes[code]).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { t } from "./index";
|
||||
// its literal keys — the kernel's parity guard reads this file as text and can
|
||||
// only see those — so kernel.test.ts holds the two spellings together.
|
||||
export const PROVIDER_EDIT_DISABLED = "provider.editing_disabled";
|
||||
// A paired device answers this for every account route, votes included.
|
||||
export const ACCOUNT_SIGNIN_DISABLED = "account.signin_disabled";
|
||||
// The write landed and only applying it to the open conversation did not.
|
||||
export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_running", "provider.saved_model_unlisted", "runtime.rebuild_failed"];
|
||||
|
||||
@@ -47,6 +49,8 @@ const SAID: Record<string, string> = {
|
||||
"provider.kind_unsupported": "无法识别「{kind}」这种接入方式",
|
||||
"provider.no_models_picked": "请至少选择一个模型",
|
||||
"provider.default_not_selected": "默认模型「{model}」不在已选择的模型中",
|
||||
"provider.display_name_too_long": "显示名称最多 {max} 个字符",
|
||||
"provider.display_name_invalid": "显示名称不能包含换行或其他控制字符",
|
||||
|
||||
// ── 来源:这个协议做不到 ─────────────────────────────────────────
|
||||
"provider.no_thinking_param": "该协议不发送思考参数,启用后不会生效",
|
||||
@@ -67,6 +71,10 @@ const SAID: Record<string, string> = {
|
||||
"request.method_not_allowed": "该地址不接受此种请求方式",
|
||||
"request.bad_content_type": "请求体必须是 application/json",
|
||||
"permissions.editing_disabled": "这台服务器未开放权限编辑",
|
||||
"permissions.rule_required": "请选择要收回的授权规则",
|
||||
"permissions.busy": "任务正在运行,请先停止再收回已记住的授权",
|
||||
"permissions.rebuild_unavailable": "当前会话无法重建,授权未更改",
|
||||
"project_grants.unavailable": "无法更新当前项目的授权记录,请检查用户目录中的授权文件",
|
||||
"sandbox.editing_disabled": "这台服务器未开放沙箱编辑",
|
||||
"browser_tools.editing_disabled": "这台服务器未开放内置浏览器设置",
|
||||
"roles.editing_disabled": "这台服务器未开放角色编辑",
|
||||
@@ -80,6 +88,22 @@ const SAID: Record<string, string> = {
|
||||
"roles.model_unknown": "没有已配置的模型匹配「{model}」",
|
||||
"shell.editing_disabled": "这台服务器未开放 shell 设置",
|
||||
"account.signin_disabled": "这台服务器未开放账号登录",
|
||||
"backup.signed_out": "登录账号后才能使用云备份",
|
||||
"backup.cloud_unavailable": "暂时连不上备份服务,稍后再试:{detail}",
|
||||
"backup.label_too_long": "备注最多 {max} 个字",
|
||||
"backup.collect_failed": "读取本机配置时出错:{detail}",
|
||||
"backup.preview_failed": "无法和本机配置比对:{detail}",
|
||||
"backup.weak_passphrase": "加密口令至少 {min} 个字符",
|
||||
"backup.bad_categories": "至少选择一类要备份的内容",
|
||||
"backup.cannot_decrypt": "口令不对,或这份备份已损坏",
|
||||
"backup.unsupported_format": "这份备份来自更新的版本,请先升级 Studio",
|
||||
"backup.malformed": "这份备份的内容无法识别",
|
||||
"backup.too_large": "备份超出了大小上限",
|
||||
"backup.plan_expired": "预览已失效,请重新打开这份备份",
|
||||
"backup.unknown_item": "所选的项目不在这份备份里",
|
||||
"backup.not_found": "这份备份已不存在",
|
||||
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
|
||||
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
|
||||
"workspace.changing_disabled": "这台服务器不支持切换工作区",
|
||||
"settings.unknown_preset": "不存在该预设",
|
||||
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
|
||||
@@ -91,6 +115,8 @@ const SAID: Record<string, string> = {
|
||||
"provider.bad_max_output_tokens": "最大输出 Token 不能是负数",
|
||||
"provider.bad_reasoning_protocol": "无法识别「{protocol}」这种思考协议",
|
||||
"provider.default_effort_not_listed": "默认档位「{level}」不在填写的档位里",
|
||||
"provider.model_default_effort_not_listed": "{model} 的默认档位「{level}」不在为它选的档位里",
|
||||
"provider.model_effort_unlisted": "{model} 不在已启用的模型里,不能为它单独设置档位",
|
||||
"provider.running": "这个模型上的对话正在运行,停止后再删除",
|
||||
"session.running": "该会话正在运行,停止后再删除",
|
||||
"workspace.running": "这个文件夹里有正在运行的对话,停止后再移除",
|
||||
@@ -152,6 +178,7 @@ const SAID: Record<string, string> = {
|
||||
"{provider} 没说自己有哪些推理强度档位。要有,得在它的配置块里写 reasoning_protocol 或 supported_efforts",
|
||||
"effort.unsupported_level": "{provider} 不提供「{level}」档位,可选值为:{levels}",
|
||||
"effort.no_provider": "无法识别当前使用的来源,请先切换一次模型",
|
||||
"model_mode.unsupported": "当前模型 {model} 不支持「{mode}」模式",
|
||||
|
||||
"prompt_refine.empty": "输入框是空的,先写点内容再优化",
|
||||
"prompt_refine.too_long": "内容超过 {max_bytes} 字节,无法优化",
|
||||
@@ -236,6 +263,10 @@ const SAID: Record<string, string> = {
|
||||
"compaction.no_soft_limit": "本次请求未包含阈值,未做任何修改",
|
||||
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
|
||||
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
|
||||
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
|
||||
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
|
||||
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
|
||||
"progress_watch.out_of_range": "该数值超出允许范围,未做任何修改:{detail}",
|
||||
"mcp.bad_declaration": "无法解析该服务器声明:{detail}",
|
||||
"mcp.install_failed": "未能安装该服务器:{detail}",
|
||||
"mcp.remove_failed": "未能移除该服务器:{detail}",
|
||||
@@ -252,6 +283,30 @@ const SAID: Record<string, string> = {
|
||||
"install.request_unreadable": "无法解析本次安装请求:{detail}",
|
||||
"install.failed": "安装失败:{detail}",
|
||||
"install.bad_answer": "无法解析安装器的响应:{detail}",
|
||||
"market.bad_slug": "这不是有效的社区包名称",
|
||||
"market.not_found": "社区市场中没有这个已审核的包",
|
||||
"market.unreachable": "无法连接社区市场:{detail}",
|
||||
"market.bad_response": "社区市场返回了无法识别的内容",
|
||||
"market.filter_unsupported": "社区市场暂不支持只列出已固定内容的包",
|
||||
"market.unpinned": "该包的审核版本没有固定内容,需要信任发布者后才能安装",
|
||||
"market.bad_source": "该包的审核版本指向本机或不安全的来源,不从市场安装",
|
||||
"market.version_changed": "审核版本已更新,请重新查看后再安装",
|
||||
"market.content_changed": "来源内容已与审核版本不同,已拒绝安装",
|
||||
"market.not_pinnable": "该来源的内容无法固定,已拒绝安装",
|
||||
"market.plan_changed": "来源内容在确认后发生了变化,请重新查看",
|
||||
"market.not_theme": "这个标为主题的包还会安装主题以外的内容,已拒绝安装",
|
||||
"market.signed_out": "登录账号后才能发布或评价",
|
||||
"market.email_unverified": "请先在 id.reasonix.io 验证账号邮箱,再发布或评价",
|
||||
"market.not_owner": "这个名称已被其他发布者使用,请换一个名称",
|
||||
"market.version_exists": "这个版本号已经发布过,请换一个版本号",
|
||||
"market.rate_limited": "请求太频繁,请稍等一分钟再试",
|
||||
"market.unpublishable": "这个来源审核通过后也无法从市场安装:插件和主题要指向固定到提交的 GitHub 目录(tree/40 位提交号),技能要用 https 地址,MCP 用 https 地址或 npm 包名",
|
||||
"market.rejected": "社区市场拒绝了这次提交:{detail}",
|
||||
"market.not_yours": "你的发布里没有这个包",
|
||||
"market.not_private": "只有私有的包才能提交审核",
|
||||
"market.unpreviewed": "未审核的版本要先预览,再按预览时的内容摘要安装",
|
||||
"market.own_package": "不能评价自己发布的包",
|
||||
"market.bad_vote": "评价只能是赞、踩或撤回",
|
||||
"theme.unreadable": "无法读取该主题:{detail}",
|
||||
"theme.not_a_pack": "这不是可安装的主题:{detail}",
|
||||
"theme.install_failed": "主题未能写入磁盘:{detail}",
|
||||
@@ -289,11 +344,15 @@ const SAID: Record<string, string> = {
|
||||
"studio.pin_rejected": "版本固定未能保存:{detail}",
|
||||
"update.install_running": "已有一个版本切换正在进行,请等待其完成后重试",
|
||||
"update.install_rejected": "本次版本切换未能启动:{detail}",
|
||||
"update.restart_busy": "有 {n} 项任务正在运行,重启会中断它们",
|
||||
"update.nothing_ready": "没有已下载好的版本可供重启安装,请重新点击安装",
|
||||
|
||||
"loopback.host_rejected": "该请求未发往 Studio 监听的地址,已被拒绝",
|
||||
"loopback.origin_rejected": "该页面不属于 Studio,无法对其操作",
|
||||
"loopback.unauthorized": "缺少本次启动的凭据,请重新打开 Studio 后重试",
|
||||
"loopback.misconfigured": "本机通道未建立,请重新打开 Studio 后重试",
|
||||
"serve.host_rejected": "该服务未开启认证,只接受发往本机或其监听地址的请求",
|
||||
"auth.launch_token_required": "该服务未开启认证,修改与审批需要本次启动的令牌,请用启动时打印的链接打开",
|
||||
};
|
||||
|
||||
/** Reason is what a refused request answers with. `error` is English fallback
|
||||
|
||||
@@ -12,5 +12,10 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
permission_saved: "已记住这项授权,以后同样的操作不再询问",
|
||||
permission_covered: "已有的授权规则覆盖了这项操作,无需另存",
|
||||
permission_save_failed: "授权没能保存,只在本次会话内有效",
|
||||
project_programs_awaiting_approval: "这个项目自带的钩子等程序要你批准后才会运行",
|
||||
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
|
||||
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
|
||||
await_user: "等待你的输入",
|
||||
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
|
||||
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
|
||||
};
|
||||
@@ -14,26 +14,30 @@ import { install as installFileDrop } from "./ui/filedrop";
|
||||
import { host } from "./port/host";
|
||||
import { play } from "./boot/intro";
|
||||
import { settled } from "./boot/gate";
|
||||
import { onRemoteConnectionEnded, remoteConnectionEnded, remoteHub } from "./port/cloud_remote";
|
||||
import { onRemoteConnectionEnded, remoteConnectionEnded, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
|
||||
|
||||
function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: boolean }) {
|
||||
const [disconnected, setDisconnected] = useState(() => remote && remoteConnectionEnded());
|
||||
function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }) {
|
||||
const [ended, setEnded] = useState<RemoteEnd | null>(() => (remote ? remoteConnectionEnded() : null));
|
||||
useEffect(() => {
|
||||
if (!remote) return;
|
||||
const closed = () => setDisconnected(true);
|
||||
return onRemoteConnectionEnded(closed);
|
||||
return onRemoteConnectionEnded(setEnded);
|
||||
}, [remote]);
|
||||
const reauth = ended?.kind === "reauth";
|
||||
return (
|
||||
<>
|
||||
<App hub={hub} />
|
||||
{disconnected && (
|
||||
<div className="remote-closed" role="alert" aria-label={t("远程连接已断开")}>
|
||||
{ended && remote && (
|
||||
<div className="remote-closed" role="alert" aria-label={reauth ? t("需要重新登录") : t("远程连接已断开")}>
|
||||
<span aria-hidden="true" />
|
||||
<div>
|
||||
<b>{t("远程连接已断开")}</b>
|
||||
<p>{t("这台电脑已停止网页控制。重新连接需要再次验证设备状态。")}</p>
|
||||
<b>{reauth ? t("需要重新登录") : t("远程连接已断开")}</b>
|
||||
<p>{reauth
|
||||
? t("为了安全,远程控制在登录满 24 小时或退出登录后需要重新登录。")
|
||||
: t("这台电脑已停止网页控制。重新连接需要再次验证设备状态。")}</p>
|
||||
</div>
|
||||
<button onClick={() => location.reload()}>{t("重新连接")}</button>
|
||||
<button onClick={() => (reauth ? void signInAgain(remote) : location.reload())}>
|
||||
{reauth ? t("重新登录") : t("重新连接")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -47,6 +51,13 @@ function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: boolean }) {
|
||||
async function pick(): Promise<HubPort> {
|
||||
const remoteDevice = new URLSearchParams(location.search).get("device")?.trim();
|
||||
if (remoteDevice) return remoteHub(remoteDevice);
|
||||
// The hosted web build has no kernel behind its origin; without a device it
|
||||
// can only send the visitor to pick one.
|
||||
const remoteHome = import.meta.env.VITE_REMOTE_HOME;
|
||||
if (remoteHome) {
|
||||
location.replace(remoteHome);
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
try {
|
||||
// The hub's own list rather than a pane's /status: it answers with every
|
||||
// pane closed, which is when a reload has to find the kernel again.
|
||||
@@ -134,7 +145,7 @@ pick().then(
|
||||
installFileDrop();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RemoteAwareApp hub={hub} remote={new URLSearchParams(location.search).has("device")} />
|
||||
<RemoteAwareApp hub={hub} remote={new URLSearchParams(location.search).get("device")?.trim() || null} />
|
||||
</StrictMode>,
|
||||
);
|
||||
intro.progress(2);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Configuration backups kept in the signed-in account. The kernel seals each
|
||||
// one with the person's passphrase before it leaves; the service stores only
|
||||
// the envelope and the metadata listed here.
|
||||
export type BackupCategory = "settings" | "extensions" | "memory" | "automation" | "secrets";
|
||||
|
||||
export interface BackupCategoryInfo {
|
||||
id: BackupCategory;
|
||||
defaultOn: boolean;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
export interface BackupEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
format: number;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
categories: BackupCategory[];
|
||||
ciphertextBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BackupCatalog {
|
||||
categories: BackupCategoryInfo[];
|
||||
backups: BackupEntry[];
|
||||
limits: { maxCount: number; maxBytes: number };
|
||||
minPassphrase: number;
|
||||
}
|
||||
|
||||
export interface BackupOmission {
|
||||
kind: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface BackupCreateRequest {
|
||||
label: string;
|
||||
categories: BackupCategory[];
|
||||
passphrase: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export interface BackupCreated {
|
||||
backup: BackupEntry;
|
||||
omitted?: BackupOmission[];
|
||||
}
|
||||
|
||||
export interface BackupPathRef {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
// consent is set when restoring the item runs code on this machine, sends
|
||||
// conversations or a stored key somewhere new, replaces a stored key, or
|
||||
// imports other files into the standing instructions. The kernel refuses such an item unless it is
|
||||
// named again in consented, whatever the selection said.
|
||||
export interface BackupPlanItem {
|
||||
id: string;
|
||||
category: BackupCategory;
|
||||
kind: string;
|
||||
name: string;
|
||||
status: "new" | "changed" | "same" | "install";
|
||||
consent?: "executes" | "endpoint" | "replaces_secret" | "imports";
|
||||
summary?: string;
|
||||
previous?: string;
|
||||
paths?: BackupPathRef[];
|
||||
crossPlatform?: boolean;
|
||||
files?: number;
|
||||
details?: string[];
|
||||
content?: string;
|
||||
recommended: boolean;
|
||||
}
|
||||
|
||||
export interface BackupPlan {
|
||||
planId: string;
|
||||
createdAt: string;
|
||||
appVersion?: string;
|
||||
platform: string;
|
||||
samePlatform: boolean;
|
||||
categories: BackupCategory[];
|
||||
items: BackupPlanItem[];
|
||||
omitted?: BackupOmission[];
|
||||
}
|
||||
|
||||
export interface BackupPluginRef {
|
||||
name: string;
|
||||
source: string;
|
||||
version?: string;
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
export interface BackupApplyResult {
|
||||
applied: string[];
|
||||
plugins?: BackupPluginRef[];
|
||||
failed?: { id: string; error: string }[];
|
||||
reloadError?: string;
|
||||
}
|
||||
@@ -11,14 +11,17 @@ export interface PermissionLists {
|
||||
deny: string[];
|
||||
}
|
||||
|
||||
// What an editor needs beyond the lists: the file a save lands in, and — only
|
||||
// when a project config declares its own — the merge actually in force, which
|
||||
// an edit here cannot move.
|
||||
// Editable user lists and separate project rules that are in force. shadowedBy
|
||||
// names a project config that outranks the edited file; effective is the
|
||||
// merged boundary the controller actually loaded.
|
||||
export interface PermissionRules extends PermissionLists {
|
||||
path: string;
|
||||
// What was allowed on a prompt for this session alone. Nothing wrote it down,
|
||||
// so the file on screen is less than the agent may currently do.
|
||||
granted?: string[];
|
||||
remembered?: string[];
|
||||
rememberedPath?: string;
|
||||
rememberedErrorCode?: string;
|
||||
shadowedBy?: string;
|
||||
effective?: PermissionLists;
|
||||
}
|
||||
@@ -78,3 +81,14 @@ export interface BrowserToolsSettings {
|
||||
effective: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// When a run reads as no longer moving. Only the user file holds it: a
|
||||
// project file cannot pause the user's runs.
|
||||
export interface ProgressWatchSettings {
|
||||
pause: boolean;
|
||||
rounds: number;
|
||||
tokenMultiple: number;
|
||||
defaultRounds: number;
|
||||
defaultTokenMultiple: number;
|
||||
path: string;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLOSE_IDLE,
|
||||
CLOSE_REAUTH_REQUIRED,
|
||||
CLOSE_REVOKED,
|
||||
closeAction,
|
||||
linkCodec,
|
||||
RemoteLink,
|
||||
RemoteLinkError,
|
||||
type RemoteConnection,
|
||||
type RemoteEnd,
|
||||
} from "./cloud_link";
|
||||
|
||||
class FakeSocket extends EventTarget {
|
||||
readyState: number = WebSocket.OPEN;
|
||||
readonly sent: Array<Record<string, unknown>> = [];
|
||||
|
||||
send(wire: string) {
|
||||
this.sent.push(JSON.parse(wire) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "") {
|
||||
if (this.readyState === WebSocket.CLOSED) return;
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.dispatchEvent(Object.assign(new Event("close"), { code, reason }));
|
||||
}
|
||||
|
||||
reply(id: string, body: string, extra: Record<string, unknown> = {}) {
|
||||
this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({
|
||||
type: "desktop.response", id, index: 0, done: true, status: 200,
|
||||
body: linkCodec.bytesToBase64(new TextEncoder().encode(body)), ...extra,
|
||||
}) }));
|
||||
}
|
||||
}
|
||||
|
||||
function connection(features: string[] = [], instance = "desktop-1"): { conn: RemoteConnection; socket: FakeSocket } {
|
||||
const socket = new FakeSocket();
|
||||
return {
|
||||
socket,
|
||||
conn: {
|
||||
socket: socket as unknown as WebSocket,
|
||||
features,
|
||||
instance,
|
||||
listen(onMessage, onClose) {
|
||||
socket.addEventListener("message", (event) => onMessage(String((event as MessageEvent).data)));
|
||||
socket.addEventListener("close", (event) => {
|
||||
const closed = event as Event & { code: number; reason: string };
|
||||
onClose(closed.code, closed.reason);
|
||||
});
|
||||
},
|
||||
channel: {
|
||||
seal: async (value) => JSON.stringify(value),
|
||||
open: async (payload) => JSON.parse(payload) as Record<string, unknown>,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function request(method = "GET", path = "/rt/one/events/replay") {
|
||||
return new Request(`${location.origin}${path}`, { method, body: method === "GET" ? undefined : "{}" });
|
||||
}
|
||||
|
||||
let ends: RemoteEnd[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
ends = [];
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("relay close codes", () => {
|
||||
it("reconnects on idle, legacy expiry and dropped sockets, and stops on revocation or sign-in", () => {
|
||||
expect(closeAction(CLOSE_IDLE)).toBe("reconnect");
|
||||
expect(closeAction(4003)).toBe("reconnect");
|
||||
expect(closeAction(1006)).toBe("reconnect");
|
||||
expect(closeAction(1012)).toBe("reconnect");
|
||||
expect(closeAction(CLOSE_REAUTH_REQUIRED)).toBe("reauth");
|
||||
expect(closeAction(CLOSE_REVOKED)).toBe("end");
|
||||
expect(closeAction(4004)).toBe("end");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote link", () => {
|
||||
it("resends an in-flight read on the next connection and resolves it there", async () => {
|
||||
const first = connection();
|
||||
const second = connection();
|
||||
const dial = vi.fn(async () => second.conn);
|
||||
const link = new RemoteLink(first.conn, dial, (end) => ends.push(end));
|
||||
|
||||
const answer = link.fetch(request());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const id = String(first.socket.sent[0]?.id);
|
||||
first.socket.close(CLOSE_IDLE, "Idle timeout");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(dial).toHaveBeenCalledOnce();
|
||||
expect(second.socket.sent[0]?.id).toBe(id);
|
||||
second.socket.reply(id, "late but whole");
|
||||
await expect((await answer).text()).resolves.toBe("late but whole");
|
||||
expect(ends).toEqual([]);
|
||||
});
|
||||
|
||||
it("queues requests made while reconnecting and sends them once connected", async () => {
|
||||
const first = connection();
|
||||
const second = connection();
|
||||
const link = new RemoteLink(first.conn, async () => second.conn, (end) => ends.push(end));
|
||||
|
||||
first.socket.close(4003, "Admission expired");
|
||||
const answer = link.fetch(request("POST", "/rt/one/send"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(first.socket.sent).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const id = String(second.socket.sent[0]?.id);
|
||||
second.socket.reply(id, "sent");
|
||||
await expect((await answer).text()).resolves.toBe("sent");
|
||||
});
|
||||
|
||||
it("never resends a state change to a desktop that cannot recognise the resend", async () => {
|
||||
const first = connection();
|
||||
const second = connection();
|
||||
const link = new RemoteLink(first.conn, async () => second.conn, (end) => ends.push(end));
|
||||
|
||||
const answer = link.fetch(request("POST", "/rt/one/send"));
|
||||
const rejected = answer.catch((error: Error) => error);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
first.socket.close(1006, "");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(await rejected).toBeInstanceOf(Error);
|
||||
expect(second.socket.sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("resends a state change under the same id when the desktop replays answers", async () => {
|
||||
const first = connection(["replay"]);
|
||||
const second = connection(["replay"]);
|
||||
const link = new RemoteLink(first.conn, async () => second.conn, (end) => ends.push(end));
|
||||
|
||||
const answer = link.fetch(request("POST", "/rt/one/send"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const id = String(first.socket.sent[0]?.id);
|
||||
first.socket.close(1006, "");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(second.socket.sent[0]?.id).toBe(id);
|
||||
second.socket.reply(id, "once");
|
||||
await expect((await answer).text()).resolves.toBe("once");
|
||||
});
|
||||
|
||||
it("does not resend a state change to a restarted desktop, whose replay cache is empty", async () => {
|
||||
const first = connection(["replay"], "desktop-1");
|
||||
const second = connection(["replay"], "desktop-2");
|
||||
const link = new RemoteLink(first.conn, async () => second.conn, (end) => ends.push(end));
|
||||
|
||||
const rejected = link.fetch(request("POST", "/rt/one/send")).catch((error: Error) => error);
|
||||
const read = link.fetch(request());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
first.socket.close(1006, "");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(await rejected).toBeInstanceOf(Error);
|
||||
expect(second.socket.sent).toHaveLength(1);
|
||||
expect(second.socket.sent[0]?.method).toBe("GET");
|
||||
second.socket.reply(String(second.socket.sent[0]?.id), "read again");
|
||||
await expect((await read).text()).resolves.toBe("read again");
|
||||
});
|
||||
|
||||
it("sends commands in the order they were sealed, however long sealing takes", async () => {
|
||||
const { conn, socket } = connection();
|
||||
let slow = true;
|
||||
conn.channel.seal = async (value) => {
|
||||
if (slow) {
|
||||
slow = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
const link = new RemoteLink(conn, vi.fn(), (end) => ends.push(end));
|
||||
|
||||
void link.fetch(request("POST", "/first"));
|
||||
void link.fetch(request("POST", "/second"));
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(socket.sent.map((command) => command.path)).toEqual(["/first", "/second"]);
|
||||
});
|
||||
|
||||
it("asks for a sign-in instead of reconnecting when the relay says so", async () => {
|
||||
const first = connection();
|
||||
const dial = vi.fn();
|
||||
const link = new RemoteLink(first.conn, dial, (end) => ends.push(end));
|
||||
const pending = link.fetch(request()).catch((error: Error) => error);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
first.socket.close(CLOSE_REAUTH_REQUIRED, "Sign-in required");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(dial).not.toHaveBeenCalled();
|
||||
expect(ends).toEqual([{ kind: "reauth", reason: "Sign-in required" }]);
|
||||
expect(await pending).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("stops for good when the device is revoked", async () => {
|
||||
const first = connection();
|
||||
const dial = vi.fn();
|
||||
new RemoteLink(first.conn, dial, (end) => ends.push(end));
|
||||
|
||||
first.socket.close(CLOSE_REVOKED, "Access revoked");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(dial).not.toHaveBeenCalled();
|
||||
expect(ends).toEqual([{ kind: "ended", reason: "Access revoked" }]);
|
||||
});
|
||||
|
||||
it("ends with the account service's reason when a reconnect is refused", async () => {
|
||||
const first = connection();
|
||||
const dial = vi.fn()
|
||||
.mockRejectedValueOnce(new RemoteLinkError("transient", "relay busy"))
|
||||
.mockRejectedValueOnce(new RemoteLinkError("reauth", "sign in"));
|
||||
new RemoteLink(first.conn, dial, (end) => ends.push(end));
|
||||
|
||||
first.socket.close(CLOSE_IDLE, "Idle timeout");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(dial).toHaveBeenCalledTimes(2);
|
||||
expect(ends).toEqual([{ kind: "reauth", reason: "sign in" }]);
|
||||
});
|
||||
|
||||
it("rebuilds the encrypted session when the desktop goes silent on an open socket", async () => {
|
||||
const first = connection();
|
||||
const second = connection();
|
||||
const dial = vi.fn(async () => second.conn);
|
||||
const link = new RemoteLink(first.conn, dial, (end) => ends.push(end), { requestTimeoutMs: 30_000 });
|
||||
|
||||
const answer = link.fetch(request());
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(first.socket.readyState).toBe(WebSocket.CLOSED);
|
||||
expect(dial).toHaveBeenCalledOnce();
|
||||
const id = String(second.socket.sent[0]?.id);
|
||||
second.socket.reply(id, "fresh session");
|
||||
await expect((await answer).text()).resolves.toBe("fresh session");
|
||||
});
|
||||
|
||||
it("gives up after its retries and says the connection ended", async () => {
|
||||
const first = connection();
|
||||
const dial = vi.fn(async () => { throw new RemoteLinkError("transient", "offline"); });
|
||||
new RemoteLink(first.conn, dial, (end) => ends.push(end), { retryDelaysMs: [10, 10] });
|
||||
|
||||
first.socket.close(1006, "");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(dial).toHaveBeenCalledTimes(2);
|
||||
expect(ends[0]?.kind).toBe("ended");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { t } from "../i18n";
|
||||
|
||||
// Relay close codes, decided by the relay and read here by number alone.
|
||||
export const CLOSE_IDLE = 4408;
|
||||
export const CLOSE_REAUTH_REQUIRED = 4401;
|
||||
export const CLOSE_REVOKED = 4403;
|
||||
export const CLOSE_DISCONNECTED_BY_DEVICE = 4004;
|
||||
const CLOSE_RESYNC = 4000;
|
||||
|
||||
export type CloseAction = "reconnect" | "reauth" | "end";
|
||||
|
||||
export function closeAction(code: number): CloseAction {
|
||||
if (code === CLOSE_REAUTH_REQUIRED) return "reauth";
|
||||
if (code === CLOSE_REVOKED || code === CLOSE_DISCONNECTED_BY_DEVICE) return "end";
|
||||
// Protocol violations would repeat on a new connection.
|
||||
if (code === 1003 || code === 1008 || code === 1009) return "end";
|
||||
return "reconnect";
|
||||
}
|
||||
|
||||
export interface RemoteEnd {
|
||||
kind: "ended" | "reauth";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Why a (re)connection attempt failed, as the account service or the relay
|
||||
// said it: "transient" is worth another attempt, the others are not.
|
||||
export class RemoteLinkError extends Error {
|
||||
constructor(readonly kind: "reauth" | "ended" | "transient", readonly reason: string) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoteChannel {
|
||||
seal(value: unknown): Promise<string>;
|
||||
open(payload: string): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface RemoteConnection {
|
||||
socket: WebSocket;
|
||||
channel: RemoteChannel;
|
||||
features: string[];
|
||||
instance: string;
|
||||
listen(onMessage: (data: string) => void, onClose: (code: number, reason: string) => void): void;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
id: string;
|
||||
method: string;
|
||||
command: Record<string, unknown>;
|
||||
sentTo: RemoteConnection | null;
|
||||
chunks: Map<number, string>;
|
||||
status?: number;
|
||||
contentType?: string;
|
||||
etag?: string;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
resolve: (value: Response) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
}
|
||||
|
||||
export interface RemoteLinkOptions {
|
||||
requestTimeoutMs: number;
|
||||
retryDelaysMs: number[];
|
||||
}
|
||||
|
||||
const DEFAULTS: RemoteLinkOptions = {
|
||||
requestTimeoutMs: 30_000,
|
||||
retryDelaysMs: [500, 1_000, 2_000, 4_000, 8_000, 15_000],
|
||||
};
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (let at = 0; at < bytes.length; at += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
|
||||
}
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4);
|
||||
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function concatChunks(chunks: Map<number, string>) {
|
||||
const parts = [...chunks.entries()].sort(([a], [b]) => a - b).map(([, value]) => base64ToBytes(value));
|
||||
const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
joined.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
// Carries desktop requests over the relay and keeps doing so across relay
|
||||
// reconnects. A request that never left, or one the desktop can safely see
|
||||
// twice, is resent on the next connection under the same id; a state-changing
|
||||
// one already sent to a desktop that cannot recognise a resend is failed
|
||||
// instead, so it never runs twice.
|
||||
export class RemoteLink {
|
||||
private current: RemoteConnection | null = null;
|
||||
private readonly pending = new Map<string, Pending>();
|
||||
private reconnecting = false;
|
||||
private ended = false;
|
||||
private lastHeardAt = Date.now();
|
||||
private sending: Promise<void> = Promise.resolve();
|
||||
private readonly options: RemoteLinkOptions;
|
||||
|
||||
constructor(
|
||||
first: RemoteConnection,
|
||||
private readonly dial: () => Promise<RemoteConnection>,
|
||||
private readonly onEnd: (end: RemoteEnd) => void,
|
||||
options: Partial<RemoteLinkOptions> = {},
|
||||
) {
|
||||
this.options = { ...DEFAULTS, ...options };
|
||||
this.attach(first);
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (this.ended) throw new Error(t("远程连接已断开,请重新连接。"));
|
||||
const id = crypto.randomUUID();
|
||||
const body = new Uint8Array(await request.arrayBuffer());
|
||||
const command = {
|
||||
v: 1, type: "desktop.request", id, method: request.method,
|
||||
path: request.url.slice(location.origin.length), body: bytesToBase64(body),
|
||||
};
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const entry: Pending = {
|
||||
id, method: request.method.toUpperCase(), command, sentTo: null, chunks: new Map(), resolve, reject,
|
||||
};
|
||||
this.pending.set(id, entry);
|
||||
void this.send(entry);
|
||||
});
|
||||
}
|
||||
|
||||
private attach(connection: RemoteConnection) {
|
||||
this.current = connection;
|
||||
this.lastHeardAt = Date.now();
|
||||
connection.listen((data) => {
|
||||
if (this.current !== connection) return;
|
||||
this.lastHeardAt = Date.now();
|
||||
void this.receive(connection, data);
|
||||
}, (code, reason) => {
|
||||
if (this.current === connection) this.lost(code, reason);
|
||||
});
|
||||
}
|
||||
|
||||
// Commands are numbered as they are sealed, so they leave in sealing order.
|
||||
private send(entry: Pending): Promise<void> {
|
||||
this.sending = this.sending.then(() => this.sendNow(entry), () => this.sendNow(entry));
|
||||
return this.sending;
|
||||
}
|
||||
|
||||
private async sendNow(entry: Pending) {
|
||||
const connection = this.current;
|
||||
if (!connection || connection.socket.readyState !== WebSocket.OPEN || !this.pending.has(entry.id)) return;
|
||||
const wire = await connection.channel.seal(entry.command);
|
||||
if (this.current !== connection || connection.socket.readyState !== WebSocket.OPEN || !this.pending.has(entry.id)) return;
|
||||
entry.chunks.clear();
|
||||
entry.sentTo = connection;
|
||||
clearTimeout(entry.timer);
|
||||
entry.timer = setTimeout(() => this.timedOut(entry), this.options.requestTimeoutMs);
|
||||
connection.socket.send(wire);
|
||||
}
|
||||
|
||||
// A desktop that has said nothing at all for a whole request timeout has lost
|
||||
// this connection's encrypted session; one that is merely slow has not.
|
||||
private timedOut(entry: Pending) {
|
||||
if (!this.pending.has(entry.id)) return;
|
||||
if (this.current && Date.now() - this.lastHeardAt >= this.options.requestTimeoutMs) {
|
||||
const stale = this.current;
|
||||
this.lost(CLOSE_RESYNC, "Desktop stopped answering");
|
||||
try { stale.socket.close(CLOSE_RESYNC, "Resync"); } catch { /* already closing */ }
|
||||
return;
|
||||
}
|
||||
this.pending.delete(entry.id);
|
||||
entry.reject(new Error(t("远程 Studio 暂无响应,请检查电脑是否在线后重试。")));
|
||||
}
|
||||
|
||||
private lost(code: number, reason: string) {
|
||||
if (this.ended) return;
|
||||
this.current = null;
|
||||
const action = closeAction(code);
|
||||
if (action !== "reconnect") {
|
||||
this.finish({ kind: action === "reauth" ? "reauth" : "ended", reason });
|
||||
return;
|
||||
}
|
||||
for (const entry of this.pending.values()) clearTimeout(entry.timer);
|
||||
this.settleUnanswered((sentTo) => sentTo.features.includes("replay"));
|
||||
if (!this.reconnecting) void this.reconnect();
|
||||
}
|
||||
|
||||
private async reconnect() {
|
||||
this.reconnecting = true;
|
||||
try {
|
||||
for (const delay of this.options.retryDelaysMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
if (this.ended) return;
|
||||
try {
|
||||
const connection = await this.dial();
|
||||
if (this.ended) {
|
||||
connection.socket.close(1000, "Ended");
|
||||
return;
|
||||
}
|
||||
this.attach(connection);
|
||||
this.settleUnanswered((sentTo) =>
|
||||
sentTo.features.includes("replay") && sentTo.instance !== "" && sentTo.instance === connection.instance);
|
||||
for (const entry of this.pending.values()) void this.send(entry);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteLinkError && error.kind !== "transient") {
|
||||
this.finish({ kind: error.kind, reason: error.reason });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.finish({ kind: "ended", reason: t("远程 Studio 暂无响应,请检查电脑是否在线后重试。") });
|
||||
} finally {
|
||||
this.reconnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// A state change already sent is resent only where it cannot run twice: to
|
||||
// the same desktop process, which answers a resend from its replay cache.
|
||||
private settleUnanswered(canResend: (sentTo: RemoteConnection) => boolean) {
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
const idempotent = entry.method === "GET" || entry.method === "HEAD";
|
||||
if (entry.sentTo && !idempotent && !canResend(entry.sentTo)) {
|
||||
this.pending.delete(entry.id);
|
||||
entry.reject(new Error(t("连接中断,这次操作可能没有完成,请刷新后确认。")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finish(end: RemoteEnd) {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
const error = new Error(end.kind === "reauth"
|
||||
? t("远程控制需要重新登录。")
|
||||
: t("远程连接已断开,请重新连接。"));
|
||||
for (const entry of this.pending.values()) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.onEnd(end);
|
||||
}
|
||||
|
||||
private async receive(connection: RemoteConnection, wire: string) {
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
message = await connection.channel.open(wire);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const id = typeof message.id === "string" ? message.id : "";
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending || this.current !== connection) return;
|
||||
if (message.type === "error") {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
pending.reject(new Error(typeof message.error === "string" ? message.error : t("远程 Studio 拒绝了这次请求。")));
|
||||
return;
|
||||
}
|
||||
if (message.type !== "desktop.response" || typeof message.index !== "number" || typeof message.body !== "string") return;
|
||||
pending.status = typeof message.status === "number" ? message.status : pending.status;
|
||||
pending.contentType = typeof message.contentType === "string" ? message.contentType : pending.contentType;
|
||||
pending.etag = typeof message.etag === "string" ? message.etag : pending.etag;
|
||||
pending.chunks.set(message.index, message.body);
|
||||
if (message.done !== true) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
const headers = new Headers();
|
||||
if (pending.contentType) headers.set("content-type", pending.contentType);
|
||||
if (pending.etag) headers.set("etag", pending.etag);
|
||||
const status = pending.status ?? 500;
|
||||
const bytes = concatChunks(pending.chunks);
|
||||
pending.resolve(new Response(status === 204 || status === 304 ? null : bytes, { status, headers }));
|
||||
}
|
||||
}
|
||||
|
||||
export const linkCodec = { bytesToBase64, base64ToBytes, concatChunks };
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { onRemoteConnectionEnded, RemoteEventSource, remoteCodec, remoteConnectionEnded } from "./cloud_remote";
|
||||
import { openLiveStream, STREAM_SILENCE_MS } from "./livestream";
|
||||
|
||||
describe("remote Studio binary framing", () => {
|
||||
it("round-trips binary bodies and joins response chunks in index order", () => {
|
||||
@@ -44,21 +45,17 @@ describe("remote Studio binary framing", () => {
|
||||
|
||||
it("announces a relay disconnect to the Web Studio shell", () => {
|
||||
let reason = "";
|
||||
const stop = onRemoteConnectionEnded((value) => { reason = value; });
|
||||
remoteCodec.announceClosed("Disconnected by device");
|
||||
const stop = onRemoteConnectionEnded((end) => { reason = end.reason; });
|
||||
remoteCodec.announceClosed({ kind: "ended", reason: "Disconnected by device" });
|
||||
stop();
|
||||
expect(reason).toBe("Disconnected by device");
|
||||
expect(remoteConnectionEnded()).toBe(true);
|
||||
expect(remoteConnectionEnded()).toEqual({ kind: "ended", reason: "Disconnected by device" });
|
||||
});
|
||||
|
||||
it("starts polling after the host watermark instead of replaying restored history", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
frames: [{ kind: "message", seq: 7, text: "already in history" }],
|
||||
complete: true,
|
||||
watermark: 7,
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ frames: null, complete: true, watermark: 7 }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
frames: [{ kind: "message", seq: 8, text: "new reply" }],
|
||||
complete: true,
|
||||
@@ -69,15 +66,72 @@ describe("remote Studio binary framing", () => {
|
||||
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(seen).toEqual([]);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain("lastEventId=0");
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(`/rt/one/events/replay?lastEventId=${Number.MAX_SAFE_INTEGER}`);
|
||||
expect(seen).toEqual([{ kind: "stream_watermark", seq: 7 }]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(seen).toEqual([{ kind: "message", seq: 8, text: "new reply" }]);
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain("lastEventId=7");
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(String(fetchMock.mock.calls[1][0])).toBe("/rt/one/events/replay?lastEventId=7");
|
||||
expect(seen.slice(1)).toEqual([{ kind: "message", seq: 8, text: "new reply" }]);
|
||||
|
||||
source.close();
|
||||
fetchMock.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resumes from the cursor its URL carries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
|
||||
frames: [{ kind: "message", seq: 6, text: "missed while away" }],
|
||||
complete: true,
|
||||
watermark: 6,
|
||||
}), { status: 200 }));
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const source = new RemoteEventSource("/rt/one/events?lastEventId=5");
|
||||
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
source.close();
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe("/rt/one/events/replay?lastEventId=5");
|
||||
expect(seen).toEqual([{ kind: "message", seq: 6, text: "missed while away" }]);
|
||||
fetchMock.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("says so when the log no longer reaches the cursor", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
|
||||
frames: [{ kind: "message", seq: 40, text: "oldest kept" }],
|
||||
complete: false,
|
||||
watermark: 40,
|
||||
}), { status: 200 }));
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const source = new RemoteEventSource("/rt/one/events?lastEventId=5");
|
||||
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
source.close();
|
||||
expect(seen).toEqual([
|
||||
{ kind: "stream_gap", seq: 40 },
|
||||
{ kind: "message", seq: 40, text: "oldest kept" },
|
||||
]);
|
||||
fetchMock.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps an idle pane's stream under the silence watchdog", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () =>
|
||||
new Response(JSON.stringify({ frames: null, complete: true, watermark: 7 }), { status: 200 }));
|
||||
vi.stubGlobal("EventSource", RemoteEventSource);
|
||||
let opened = 0;
|
||||
const stop = openLiveStream(() => `/rt/one/events${opened++ ? "?lastEventId=7" : ""}`, () => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(STREAM_SILENCE_MS * 3);
|
||||
stop();
|
||||
expect(opened).toBe(1);
|
||||
for (const [input] of fetchMock.mock.calls) expect(String(input)).toMatch(/^\/rt\/one\/events\/replay\?/);
|
||||
fetchMock.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,43 @@
|
||||
import type { HubPort } from "./hub";
|
||||
import { SseHub } from "./hub";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { linkCodec, RemoteLink, RemoteLinkError, type RemoteConnection, type RemoteEnd } from "./cloud_link";
|
||||
|
||||
const ACCOUNT = (import.meta.env.VITE_ACCOUNTS_API || "https://id.reasonix.io").replace(/\/$/, "");
|
||||
const RELAY = (import.meta.env.VITE_REMOTE_GATEWAY || "wss://remote.reasonix.io").replace(/\/$/, "");
|
||||
const REMOTE_HOME = import.meta.env.VITE_REMOTE_HOME || "https://reasonix.io/remote/";
|
||||
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
let connectionEnded = false;
|
||||
const connectionEndedListeners = new Set<(reason: string) => void>();
|
||||
let connectionEnded: RemoteEnd | null = null;
|
||||
const connectionEndedListeners = new Set<(end: RemoteEnd) => void>();
|
||||
const { bytesToBase64, base64ToBytes, concatChunks } = linkCodec;
|
||||
|
||||
function announceClosed(reason = "") {
|
||||
connectionEnded = true;
|
||||
for (const listener of connectionEndedListeners) listener(reason);
|
||||
function announceClosed(end: RemoteEnd) {
|
||||
connectionEnded = end;
|
||||
for (const listener of connectionEndedListeners) listener(end);
|
||||
}
|
||||
|
||||
export type { RemoteEnd };
|
||||
export const remoteConnectionEnded = () => connectionEnded;
|
||||
export const onRemoteConnectionEnded = (listener: (reason: string) => void) => {
|
||||
export const onRemoteConnectionEnded = (listener: (end: RemoteEnd) => void) => {
|
||||
connectionEndedListeners.add(listener);
|
||||
return () => { connectionEndedListeners.delete(listener); };
|
||||
};
|
||||
|
||||
// Signing out first is what makes the sign-in page ask: it sends a visitor who
|
||||
// still holds a session straight on, and that session is the one too old to
|
||||
// control a computer.
|
||||
export async function signInAgain(deviceId: string, nativeFetch: typeof fetch = globalThis.fetch.bind(globalThis)) {
|
||||
try {
|
||||
await nativeFetch(`${ACCOUNT}/auth/logout`, { method: "POST", credentials: "include" });
|
||||
} catch {
|
||||
// The sign-in page still works; it will just offer to continue as the old session.
|
||||
}
|
||||
const home = new URL(REMOTE_HOME);
|
||||
home.searchParams.set("device", deviceId);
|
||||
location.href = `${home.origin}/login/?next=${encodeURIComponent(home.pathname + home.search)}`;
|
||||
}
|
||||
|
||||
interface RemoteDevice {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
@@ -27,42 +45,6 @@ interface RemoteDevice {
|
||||
revokedAt?: string | null;
|
||||
}
|
||||
|
||||
interface PendingResponse {
|
||||
chunks: Map<number, string>;
|
||||
resolve: (value: Response) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
status?: number;
|
||||
contentType?: string;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (let at = 0; at < bytes.length; at += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
|
||||
}
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4);
|
||||
const binary = atob(padded);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function concatChunks(chunks: Map<number, string>) {
|
||||
const parts = [...chunks.entries()].sort(([a], [b]) => a - b).map(([, value]) => base64ToBytes(value));
|
||||
const size = parts.reduce((total, part) => total + part.length, 0);
|
||||
const joined = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
joined.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
async function encryptedChannel(device: RemoteDevice, socket: WebSocket) {
|
||||
const pair = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
|
||||
const deviceKey = await crypto.subtle.importKey("raw", base64ToBytes(device.publicKey), { name: "X25519" }, false, []);
|
||||
@@ -81,10 +63,21 @@ async function encryptedChannel(device: RemoteDevice, socket: WebSocket) {
|
||||
socket.send(JSON.stringify({
|
||||
v: 1, type: "hello", publicKey: bytesToBase64(publicKey), salt: bytesToBase64(salt),
|
||||
}));
|
||||
// Once the desktop names this session, every command carries that name and
|
||||
// an increasing number, so a relay cannot replay a command into a later
|
||||
// session or twice into this one.
|
||||
let bound: string | null = null;
|
||||
let seq = 0;
|
||||
return {
|
||||
bind(session: string) {
|
||||
bound = session;
|
||||
},
|
||||
async seal(value: unknown) {
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plain = new TextEncoder().encode(JSON.stringify(value));
|
||||
const stamped = bound && value && typeof value === "object"
|
||||
? { ...(value as Record<string, unknown>), session: bound, seq: ++seq }
|
||||
: value;
|
||||
const plain = new TextEncoder().encode(JSON.stringify(stamped));
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: info }, key, plain);
|
||||
return JSON.stringify({ v: 1, nonce: bytesToBase64(nonce), ciphertext: bytesToBase64(new Uint8Array(ciphertext)) });
|
||||
},
|
||||
@@ -141,94 +134,28 @@ function waitForSocketOpen(socket: WebSocket, timeout = HANDSHAKE_TIMEOUT_MS): P
|
||||
});
|
||||
}
|
||||
|
||||
class RemoteTransport {
|
||||
private readonly pending = new Map<string, PendingResponse>();
|
||||
|
||||
constructor(
|
||||
private readonly socket: WebSocket,
|
||||
private readonly channel: Awaited<ReturnType<typeof encryptedChannel>>,
|
||||
) {
|
||||
socket.addEventListener("message", (event) => void this.receive(String(event.data)));
|
||||
socket.addEventListener("close", (event) => {
|
||||
this.failAll(new Error(t("远程连接已断开,请重新连接。")));
|
||||
announceClosed(event.reason);
|
||||
});
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (this.socket.readyState !== WebSocket.OPEN) throw new Error(t("远程连接已断开,请重新连接。"));
|
||||
const id = crypto.randomUUID();
|
||||
const body = new Uint8Array(await request.arrayBuffer());
|
||||
const response = new Promise<Response>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(t("远程 Studio 暂无响应,请检查电脑是否在线后重试。")));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
this.pending.set(id, { chunks: new Map(), resolve, reject, timer });
|
||||
});
|
||||
const payload = await this.channel.seal({
|
||||
v: 1, type: "desktop.request", id, method: request.method,
|
||||
path: request.url.slice(location.origin.length), body: bytesToBase64(body),
|
||||
});
|
||||
if (this.socket.readyState !== WebSocket.OPEN) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
}
|
||||
throw new Error(t("远程连接已断开,请重新连接。"));
|
||||
}
|
||||
this.socket.send(payload);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async receive(wire: string) {
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
message = await this.channel.open(wire);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const id = typeof message.id === "string" ? message.id : "";
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
if (message.type === "error") {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
pending.reject(new Error(typeof message.error === "string" ? message.error : t("远程 Studio 拒绝了这次请求。")));
|
||||
return;
|
||||
}
|
||||
if (message.type !== "desktop.response" || typeof message.index !== "number" || typeof message.body !== "string") return;
|
||||
pending.status = typeof message.status === "number" ? message.status : pending.status;
|
||||
pending.contentType = typeof message.contentType === "string" ? message.contentType : pending.contentType;
|
||||
pending.etag = typeof message.etag === "string" ? message.etag : pending.etag;
|
||||
pending.chunks.set(message.index, message.body);
|
||||
if (message.done !== true) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
const headers = new Headers();
|
||||
if (pending.contentType) headers.set("content-type", pending.contentType);
|
||||
if (pending.etag) headers.set("etag", pending.etag);
|
||||
const status = pending.status ?? 500;
|
||||
const bytes = concatChunks(pending.chunks);
|
||||
pending.resolve(new Response(status === 204 || status === 304 ? null : bytes, { status, headers }));
|
||||
}
|
||||
|
||||
private failAll(error: Error) {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
interface ReplayAnswer {
|
||||
frames?: Array<Record<string, unknown>> | null;
|
||||
complete?: boolean;
|
||||
watermark?: number;
|
||||
}
|
||||
|
||||
// Stands in for EventSource over the relay by polling /events/replay. A cursor
|
||||
// the URL carries is where a replacement stream resumes; without one it attaches
|
||||
// at the watermark, and asking past it returns no frames, not the whole log.
|
||||
// Frames the SSE transport writes on its own (the watermark, a gap) are said
|
||||
// here too, or the stream reads as silent to whoever watches it.
|
||||
export class RemoteEventSource {
|
||||
onmessage: ((event: MessageEvent<string>) => void) | null = null;
|
||||
private closed = false;
|
||||
private after: number | null = null;
|
||||
private after: number | null;
|
||||
private readonly replay: string;
|
||||
|
||||
constructor(private readonly path: string) {
|
||||
constructor(url: string) {
|
||||
const target = new URL(url, location.origin);
|
||||
const cursor = Number(target.searchParams.get("lastEventId") ?? "");
|
||||
this.after = target.searchParams.has("lastEventId") && Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : null;
|
||||
this.replay = `${target.pathname}/replay`;
|
||||
queueMicrotask(() => void this.poll());
|
||||
}
|
||||
|
||||
@@ -236,28 +163,32 @@ export class RemoteEventSource {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
private say(frame: Record<string, unknown>) {
|
||||
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(frame) }));
|
||||
}
|
||||
|
||||
private async poll() {
|
||||
while (!this.closed) {
|
||||
let active = false;
|
||||
try {
|
||||
const path = this.path.replace(/\/events$/, `/events/replay?lastEventId=${this.after ?? 0}`);
|
||||
const response = await fetch(path, { credentials: "same-origin" });
|
||||
if (response.ok) {
|
||||
const body = await response.json() as { frames?: Array<Record<string, unknown>>; watermark?: number };
|
||||
active = Boolean(body.frames?.length);
|
||||
if (this.after === null) {
|
||||
const frameWatermark = (body.frames ?? []).reduce(
|
||||
(latest, frame) => typeof frame.seq === "number" ? Math.max(latest, frame.seq) : latest,
|
||||
0,
|
||||
);
|
||||
this.after = typeof body.watermark === "number" ? body.watermark : frameWatermark;
|
||||
} else {
|
||||
for (const frame of body.frames ?? []) {
|
||||
const seq = typeof frame.seq === "number" ? frame.seq : 0;
|
||||
if (seq > this.after) this.after = seq;
|
||||
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(frame) }));
|
||||
}
|
||||
const response = await fetch(`${this.replay}?lastEventId=${this.after ?? Number.MAX_SAFE_INTEGER}`, { credentials: "same-origin" });
|
||||
if (response.ok && !this.closed) {
|
||||
const body = await response.json() as ReplayAnswer;
|
||||
const watermark = typeof body.watermark === "number" ? body.watermark : null;
|
||||
const attaching = this.after === null;
|
||||
const frames = attaching ? [] : body.frames ?? [];
|
||||
active = frames.length > 0;
|
||||
if (this.after === null) this.after = watermark ?? 0;
|
||||
else if (body.complete === false) {
|
||||
const from = typeof frames[0]?.seq === "number" ? frames[0].seq : watermark ?? this.after;
|
||||
this.say({ kind: "stream_gap", seq: from });
|
||||
}
|
||||
for (const frame of frames) {
|
||||
const seq = typeof frame.seq === "number" ? frame.seq : 0;
|
||||
if (seq > this.after) this.after = seq;
|
||||
this.say(frame);
|
||||
}
|
||||
if (!active && watermark !== null) this.say({ kind: "stream_watermark", seq: watermark });
|
||||
}
|
||||
} catch {
|
||||
// The next poll retries while the encrypted session remains open.
|
||||
@@ -267,40 +198,84 @@ export class RemoteEventSource {
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(deviceId: string, nativeFetch: typeof fetch) {
|
||||
const bootstrap = (globalThis as typeof globalThis & {
|
||||
__rxRemoteBootstrap?: Promise<Response> | null;
|
||||
}).__rxRemoteBootstrap;
|
||||
async function grantFailure(issued: Response): Promise<RemoteLinkError> {
|
||||
const body = await issued.json().catch(() => null) as { error?: { code?: string } } | null;
|
||||
const code = body?.error?.code;
|
||||
if (issued.status === 401 || code === "remote_reauth_required") {
|
||||
return new RemoteLinkError("reauth", t("远程控制需要重新登录。"));
|
||||
}
|
||||
if (issued.status === 404 || code === "device_not_found") {
|
||||
return new RemoteLinkError("ended", t("这台电脑已从账号中移除。"));
|
||||
}
|
||||
return new RemoteLinkError("transient", t("无法授权 Web Studio,请重新登录后再试。"));
|
||||
}
|
||||
|
||||
async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap: boolean): Promise<RemoteConnection> {
|
||||
const scope = globalThis as typeof globalThis & { __rxRemoteBootstrap?: Promise<Response> | null };
|
||||
const bootstrap = useBootstrap ? scope.__rxRemoteBootstrap : null;
|
||||
scope.__rxRemoteBootstrap = null;
|
||||
if (!bootstrap) performance.mark("reasonix:remote:start");
|
||||
const issued = await (bootstrap ?? nativeFetch(`${ACCOUNT}/me/remote-grants`, {
|
||||
method: "POST", credentials: "include", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ targetDeviceId: deviceId, scopes: ["desktop"] }),
|
||||
}));
|
||||
if (!issued.ok) throw new Error(t("无法授权 Web Studio,请重新登录后再试。"));
|
||||
let issued: Response;
|
||||
try {
|
||||
issued = await (bootstrap ?? nativeFetch(`${ACCOUNT}/me/remote-grants`, {
|
||||
method: "POST", credentials: "include", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ targetDeviceId: deviceId, scopes: ["desktop"] }),
|
||||
}));
|
||||
} catch {
|
||||
throw new RemoteLinkError("transient", t("远程中转服务暂时不可用,请稍后重试。"));
|
||||
}
|
||||
if (!issued.ok) throw await grantFailure(issued);
|
||||
const { grant, device: target } = await issued.json() as { grant: { ticket: string }; device?: RemoteDevice };
|
||||
if (!target || target.id !== deviceId || target.revokedAt || !target.capabilities.includes("desktop")) {
|
||||
throw new Error(t("请先更新这台电脑上的 Studio,再连接。"));
|
||||
throw new RemoteLinkError("ended", t("请先更新这台电脑上的 Studio,再连接。"));
|
||||
}
|
||||
performance.mark("reasonix:remote:authorized");
|
||||
const socket = new WebSocket(`${RELAY}/v1/sessions/connect`, ["reasonix.remote.v1", `reasonix.auth.${grant.ticket}`]);
|
||||
await waitForSocketOpen(socket);
|
||||
const readyWire = nextMessage(socket);
|
||||
const channel = await encryptedChannel(target, socket);
|
||||
const ready = await channel.open(await readyWire);
|
||||
if (ready.type !== "ready" || ready.deviceId !== target.id) throw new Error(t("远程 Studio 身份校验失败,请停止连接并检查设备。"));
|
||||
performance.mark("reasonix:remote:ready");
|
||||
performance.measure("reasonix:remote:authorize", "reasonix:remote:start", "reasonix:remote:authorized");
|
||||
performance.measure("reasonix:remote:handshake", "reasonix:remote:authorized", "reasonix:remote:ready");
|
||||
performance.measure("reasonix:remote:connect", "reasonix:remote:start", "reasonix:remote:ready");
|
||||
return new RemoteTransport(socket, channel);
|
||||
try {
|
||||
await waitForSocketOpen(socket);
|
||||
const readyWire = nextMessage(socket);
|
||||
const channel = await encryptedChannel(target, socket);
|
||||
const ready = await channel.open(await readyWire);
|
||||
if (ready.type !== "ready" || ready.deviceId !== target.id) {
|
||||
throw new RemoteLinkError("ended", t("远程 Studio 身份校验失败,请停止连接并检查设备。"));
|
||||
}
|
||||
performance.mark("reasonix:remote:ready");
|
||||
performance.measure("reasonix:remote:authorize", "reasonix:remote:start", "reasonix:remote:authorized");
|
||||
performance.measure("reasonix:remote:handshake", "reasonix:remote:authorized", "reasonix:remote:ready");
|
||||
performance.measure("reasonix:remote:connect", "reasonix:remote:start", "reasonix:remote:ready");
|
||||
const features = Array.isArray(ready.features) ? ready.features.filter((item): item is string => typeof item === "string") : [];
|
||||
if (typeof ready.session === "string" && ready.session) channel.bind(ready.session);
|
||||
const instance = typeof ready.instance === "string" ? ready.instance : "";
|
||||
return {
|
||||
socket, channel, features, instance,
|
||||
listen(onMessage, onClose) {
|
||||
socket.addEventListener("message", (event) => onMessage(String(event.data)));
|
||||
socket.addEventListener("close", (event) => onClose(event.code, event.reason));
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
try { socket.close(1000, "Handshake failed"); } catch { /* never opened */ }
|
||||
if (error instanceof RemoteLinkError) throw error;
|
||||
throw new RemoteLinkError("transient", reason(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function remoteHub(deviceId: string): Promise<HubPort> {
|
||||
const nativeFetch = globalThis.fetch.bind(globalThis);
|
||||
const transport = await connect(deviceId, nativeFetch);
|
||||
let first: RemoteConnection;
|
||||
try {
|
||||
first = await connect(deviceId, nativeFetch, true);
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteLinkError && error.kind === "reauth") {
|
||||
await signInAgain(deviceId, nativeFetch);
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const link = new RemoteLink(first, () => connect(deviceId, nativeFetch, false), announceClosed);
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
return new URL(request.url).origin === location.origin ? transport.fetch(request) : nativeFetch(request);
|
||||
return new URL(request.url).origin === location.origin ? link.fetch(request) : nativeFetch(request);
|
||||
}) as typeof fetch;
|
||||
globalThis.EventSource = RemoteEventSource as unknown as typeof EventSource;
|
||||
return new SseHub();
|
||||
|
||||
@@ -44,6 +44,9 @@ export interface HostPort {
|
||||
* hide every other; hideBrowserView puts them all away again. */
|
||||
showBrowserView(target: string, rect: ViewRect): void;
|
||||
hideBrowserView(): void;
|
||||
/** Put the page on screen away the way hideBrowserView does, answering with
|
||||
* a picture of what it showed at that moment, or "" when none was taken. */
|
||||
freezeBrowserView(): Promise<string>;
|
||||
controlBrowserView(target: string, action: BrowserControl): void;
|
||||
/** Load what the person typed. false when the shell refused the address. */
|
||||
navigateBrowserView(target: string, address: string): Promise<boolean>;
|
||||
@@ -126,6 +129,7 @@ interface ElectronBridge {
|
||||
revealPath?(base: string, path: string): Promise<Refusal | null>;
|
||||
showBrowserView?(target: string, rect: ViewRect): Promise<void>;
|
||||
hideBrowserView?(): Promise<void>;
|
||||
freezeBrowserView?(): Promise<string>;
|
||||
controlBrowserView?(target: string, action: string): Promise<void>;
|
||||
navigateBrowserView?(target: string, address: string): Promise<boolean>;
|
||||
onBrowserLoadState?(listener: (state: BrowserLoadState) => void): () => void;
|
||||
@@ -197,6 +201,11 @@ class ElectronHost implements HostPort {
|
||||
hideBrowserView() {
|
||||
void this.api.hideBrowserView?.();
|
||||
}
|
||||
freezeBrowserView() {
|
||||
if (this.api.freezeBrowserView) return this.api.freezeBrowserView().catch(() => "");
|
||||
this.hideBrowserView();
|
||||
return Promise.resolve("");
|
||||
}
|
||||
controlBrowserView(target: string, action: BrowserControl) {
|
||||
void this.api.controlBrowserView?.(target, action);
|
||||
}
|
||||
@@ -253,6 +262,9 @@ class BrowserHost implements HostPort {
|
||||
}
|
||||
showBrowserView() {}
|
||||
hideBrowserView() {}
|
||||
freezeBrowserView() {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
controlBrowserView() {}
|
||||
navigateBrowserView() {
|
||||
return Promise.resolve(false);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { openLiveStream, STREAM_SILENCE_MS } from "./livestream";
|
||||
import { SsePort } from "./sse";
|
||||
|
||||
type Stream = { url: string; closed: boolean; say: (raw: string) => void };
|
||||
|
||||
function stubStreams() {
|
||||
const opened: Stream[] = [];
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
onmessage: ((m: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
readonly s: Stream;
|
||||
constructor(url: string) {
|
||||
this.s = { url, closed: false, say: (raw) => this.onmessage?.({ data: raw }) };
|
||||
opened.push(this.s);
|
||||
}
|
||||
close() {
|
||||
this.s.closed = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
return opened;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("a stream that goes silent", () => {
|
||||
it("keeps a stream that goes on hearing the kernel's watermark", () => {
|
||||
const opened = stubStreams();
|
||||
const stop = openLiveStream(() => "/events", () => {});
|
||||
for (let i = 0; i < 12; i++) {
|
||||
vi.advanceTimersByTime(15_000);
|
||||
opened[0].say('{"kind":"stream_watermark","seq":1}');
|
||||
}
|
||||
expect(opened).toHaveLength(1);
|
||||
stop();
|
||||
expect(opened[0].closed).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces one that has heard nothing for three watermark intervals", () => {
|
||||
const opened = stubStreams();
|
||||
const got: string[] = [];
|
||||
const stop = openLiveStream(() => `/events?n=${opened.length}`, (raw) => got.push(raw));
|
||||
vi.advanceTimersByTime(STREAM_SILENCE_MS + 5_000);
|
||||
expect(opened).toHaveLength(2);
|
||||
expect(opened[0].closed).toBe(true);
|
||||
expect(opened[1].url).toBe("/events?n=1");
|
||||
opened[1].say("x");
|
||||
expect(got).toEqual(["x"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("checks at once when the page comes back rather than waiting for a tick", () => {
|
||||
const opened = stubStreams();
|
||||
const stop = openLiveStream(() => "/events", () => {});
|
||||
vi.setSystemTime(Date.now() + STREAM_SILENCE_MS + 1);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(opened).toHaveLength(2);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("resumes the replacement from the last frame the pane folded", () => {
|
||||
const opened = stubStreams();
|
||||
vi.stubGlobal("fetch", async () => ({ ok: true, json: async () => ({ frames: [], complete: true }) }));
|
||||
const seen: number[] = [];
|
||||
const stop = new SsePort("", "r1").subscribe((ev) => ev.seq && seen.push(ev.seq));
|
||||
opened[0].say('{"kind":"stream_watermark","seq":0}');
|
||||
opened[0].say('{"kind":"turn_started","seq":1}');
|
||||
opened[0].say('{"kind":"turn_done","seq":2}');
|
||||
vi.advanceTimersByTime(STREAM_SILENCE_MS + 5_000);
|
||||
expect(opened[1].url).toBe("/events?lastEventId=2");
|
||||
opened[1].say('{"kind":"turn_done","seq":2}');
|
||||
opened[1].say('{"kind":"turn_started","seq":3}');
|
||||
expect(seen).toEqual([1, 2, 3]);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// The kernel restates its position on every transport once per
|
||||
// SSEWatermarkInterval (15s, internal/frontend/serve/eventstream.go). Three
|
||||
// silent intervals is a stream that is gone, whatever readyState says: a phone
|
||||
// that slept or a Wi-Fi link that dropped leaves the socket looking open.
|
||||
export const STREAM_SILENCE_MS = 45_000;
|
||||
const CHECK_MS = 5_000;
|
||||
|
||||
/** Holds one EventSource open and replaces it once it has gone silent. `url`
|
||||
* names where to resume, so a replacement asks for what the dead one missed.
|
||||
* Timers stop while a phone sleeps, so waking and coming back online check at
|
||||
* once rather than on the next tick. */
|
||||
export function openLiveStream(url: () => string, onData: (raw: string) => void, silenceMs = STREAM_SILENCE_MS): () => void {
|
||||
let es: EventSource;
|
||||
let heard = 0;
|
||||
const open = () => {
|
||||
heard = Date.now();
|
||||
es = new EventSource(url(), { withCredentials: true });
|
||||
es.onopen = () => {
|
||||
heard = Date.now();
|
||||
};
|
||||
es.onmessage = (m) => {
|
||||
heard = Date.now();
|
||||
onData(m.data);
|
||||
};
|
||||
};
|
||||
const check = () => {
|
||||
if (Date.now() - heard < silenceMs) return;
|
||||
es.close();
|
||||
open();
|
||||
};
|
||||
const wake = () => {
|
||||
if (document.visibilityState === "visible") check();
|
||||
};
|
||||
open();
|
||||
const timer = setInterval(check, CHECK_MS);
|
||||
const dom = typeof document !== "undefined" && typeof addEventListener === "function";
|
||||
if (dom) {
|
||||
document.addEventListener("visibilitychange", wake);
|
||||
addEventListener("online", check);
|
||||
}
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
if (dom) {
|
||||
document.removeEventListener("visibilitychange", wake);
|
||||
removeEventListener("online", check);
|
||||
}
|
||||
es.close();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { PluginPlan } from "./plugin";
|
||||
|
||||
// The community registry as the market tab reads it. Every text field is the
|
||||
// publisher's; what an install puts on disk is decided by the approved
|
||||
// version's pinned digest and the plan the person confirms, never by these.
|
||||
// A theme is its own category but installs as a plugin package carrying only themes.
|
||||
export type MarketKind = "skill" | "plugin" | "mcp" | "theme";
|
||||
|
||||
// What this machine holds of a listed package, read off the market's own
|
||||
// install record — and only while the things it installed are still there.
|
||||
export interface MarketInstalled {
|
||||
version: string;
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
export interface MarketPackage {
|
||||
kind: MarketKind;
|
||||
handle: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
homepage: string;
|
||||
repoUrl: string;
|
||||
tags: string[];
|
||||
latestVersion: string;
|
||||
installCount: number;
|
||||
starCount: number;
|
||||
upCount: number;
|
||||
downCount: number;
|
||||
// up / (up + down); null while nobody has voted, which is not zero approval.
|
||||
approvalRate: number | null;
|
||||
// The registry's recommended score, 0..1; the order itself is the registry's.
|
||||
score: number;
|
||||
verified: boolean;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
installed?: MarketInstalled;
|
||||
// The registry's word that the approved version is pinned; absent when it
|
||||
// did not say. The install still checks the digest itself.
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
// The version a reviewer let through. contentHash is the pin: an install is
|
||||
// refused unless the source still resolves to exactly this.
|
||||
export interface MarketVersion {
|
||||
version: string;
|
||||
source: string;
|
||||
contentHash: string;
|
||||
riskLevel: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MarketDetail {
|
||||
package: MarketPackage;
|
||||
approved?: MarketVersion;
|
||||
// False means an install needs the person's trust (market.unpinned without it).
|
||||
pinned: boolean;
|
||||
installed?: MarketInstalled;
|
||||
}
|
||||
|
||||
export interface MarketList {
|
||||
packages: MarketPackage[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface MarketQuery {
|
||||
kind?: MarketKind | "";
|
||||
q?: string;
|
||||
sort?: "recommended" | "trending" | "new" | "installs";
|
||||
offset?: number;
|
||||
// Filtered by the registry, so paging stays whole.
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
// version is the approved version the person was shown; the kernel refuses an
|
||||
// install once a different one is approved (market.version_changed). trust
|
||||
// accepts an unpinned version; digest is then the preview's contentDigest.
|
||||
export interface MarketRequest {
|
||||
slug: string;
|
||||
version?: string;
|
||||
planId?: string;
|
||||
replace?: boolean;
|
||||
trust?: boolean;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
// unreviewed marks an install of a version no reviewer pinned: contentDigest
|
||||
// is then this preview's, and apply must echo it.
|
||||
export type MarketPlan = PluginPlan & { slug: string; version: string; contentDigest?: string; unreviewed?: boolean };
|
||||
|
||||
// The account's own package, any review state. digest is the previewed
|
||||
// contentDigest; the kernel refuses an apply without it (market.unpreviewed).
|
||||
export type MarketOwnRequest = MarketRequest;
|
||||
|
||||
// One package offered for review under the signed-in account's handle.
|
||||
export interface MarketSubmission {
|
||||
kind: MarketKind;
|
||||
name: string;
|
||||
source: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
repoUrl?: string;
|
||||
version?: string;
|
||||
tags?: string[];
|
||||
// private keeps it to the publisher and out of review until submitted.
|
||||
visibility?: "public" | "private";
|
||||
}
|
||||
|
||||
// The registry's receipt: a new submission lands as pending until approved.
|
||||
export interface MarketPublished {
|
||||
package: MarketPackage;
|
||||
created: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export type MarketReviewStatus = "pending" | "active" | "rejected" | "hidden" | "private";
|
||||
|
||||
// The signed-in person's vote on one package. signedIn false is an answer, not
|
||||
// an error: the entry offers sign-in instead of the buttons.
|
||||
export interface MarketVote {
|
||||
signedIn: boolean;
|
||||
value: -1 | 0 | 1;
|
||||
upCount?: number;
|
||||
downCount?: number;
|
||||
approvalRate?: number | null;
|
||||
canVote?: boolean;
|
||||
own?: boolean;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { PlanAction } from "./session";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockTheme } from "./mock_theme";
|
||||
import { MockBackup } from "./mock_backup";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
|
||||
import { mockStorage, mockStoragePlan } from "./mock_storage";
|
||||
@@ -11,7 +11,7 @@ import { mockUsage } from "./mock_usage";
|
||||
|
||||
|
||||
|
||||
export class MockPort extends MockTheme implements AgentPort {
|
||||
export class MockPort extends MockBackup implements AgentPort {
|
||||
private listeners = new Set<(ev: WireEvent) => void>();
|
||||
private log: WireEvent[] = [];
|
||||
// What the user has sent, so checkpoints() can mirror one per turn.
|
||||
@@ -204,13 +204,14 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
async goToVersion(): Promise<void> {
|
||||
throw new Error("演示模式不会真的安装版本");
|
||||
}
|
||||
async restartToVersion(): Promise<void> { throw new Error("演示模式不会真的安装版本"); }
|
||||
|
||||
onUpdateProgress(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async account(): Promise<AccountState> {
|
||||
return { signedIn: false };
|
||||
return localStorage.getItem("reasonix.mock.account") ? { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } } : { signedIn: false };
|
||||
}
|
||||
|
||||
async accountLogin(): Promise<DeviceGrant> {
|
||||
@@ -408,12 +409,13 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
};
|
||||
}
|
||||
|
||||
async workspaceFiles(path = "", query = "") {
|
||||
const all = ["README.md", "internal/provider/retry.go"];
|
||||
async workspaceFiles(path = "", query = "", hidden = false) {
|
||||
const all = ["README.md", "internal/provider/retry.go", ...(hidden ? [".gitignore", ".reasonix/REASONIX.local.md"] : [])];
|
||||
if (query) return { files: all.filter((p) => p.toLowerCase().includes(query.toLowerCase())), directories: [] };
|
||||
if (path === "internal") return { files: [], directories: ["internal/provider"] };
|
||||
if (path === "internal/provider") return { files: ["internal/provider/retry.go"], directories: [] };
|
||||
return { files: ["README.md"], directories: ["internal"] };
|
||||
if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] };
|
||||
return hidden ? { files: [".gitignore", "README.md"], directories: [".reasonix", "internal"] } : { files: ["README.md"], directories: ["internal"] };
|
||||
}
|
||||
async workspaceFile(path: string) { return { path, content: `// ${path}\n`, revision: "fixture" }; }
|
||||
workspaceImageURL(path: string) { return `/fixture/${path}`; }
|
||||
@@ -761,8 +763,8 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
async setPreset(preset: Preset) {
|
||||
this.state.preset = preset;
|
||||
}
|
||||
async storage() {
|
||||
return mockStorage();
|
||||
async storage(query?: Parameters<typeof mockStorage>[0]) {
|
||||
return mockStorage(query);
|
||||
}
|
||||
|
||||
async planStorageMove(root: string, dir: string) {
|
||||
@@ -780,6 +782,7 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
async setEffort(effort: string) {
|
||||
this.state.effort = effort;
|
||||
}
|
||||
async setModelMode(mode: string) { this.state.modes = this.state.modes?.map((m) => ({ ...m, active: m.id === mode })); }
|
||||
async setGoal(text: string) {
|
||||
this.state.goal = text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { MockTheme } from "./mock_theme";
|
||||
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupEntry, BackupPlan } from "./backup";
|
||||
|
||||
const CATEGORIES: BackupCatalog["categories"] = [
|
||||
{ id: "settings", defaultOn: true, consent: false },
|
||||
{ id: "extensions", defaultOn: true, consent: false },
|
||||
{ id: "memory", defaultOn: true, consent: false },
|
||||
{ id: "automation", defaultOn: true, consent: true },
|
||||
{ id: "secrets", defaultOn: false, consent: false },
|
||||
];
|
||||
|
||||
// The fixture keeps what it was handed in memory, so the settings section can
|
||||
// be driven end to end with no account service behind it.
|
||||
export class MockBackup extends MockTheme {
|
||||
private backupList: BackupEntry[] = [];
|
||||
|
||||
async backups(): Promise<BackupCatalog> {
|
||||
return { categories: CATEGORIES, backups: this.backupList, limits: { maxCount: 10, maxBytes: 4 << 20 }, minPassphrase: 10 };
|
||||
}
|
||||
|
||||
async createBackup(req: BackupCreateRequest): Promise<BackupCreated> {
|
||||
const backup: BackupEntry = {
|
||||
id: "b" + (this.backupList.length + 1), label: req.label, format: 1, appVersion: "demo",
|
||||
platform: "darwin/arm64", categories: req.categories, ciphertextBytes: 18432, createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.backupList = [backup, ...this.backupList];
|
||||
return { backup };
|
||||
}
|
||||
|
||||
async deleteBackup(id: string): Promise<void> {
|
||||
this.backupList = this.backupList.filter((b) => b.id !== id);
|
||||
}
|
||||
|
||||
async previewBackup(): Promise<BackupPlan> {
|
||||
return {
|
||||
planId: "demo", createdAt: new Date().toISOString(), platform: "darwin/arm64", samePlatform: true,
|
||||
categories: ["settings", "automation"],
|
||||
items: [
|
||||
{ id: "provider:deepseek", category: "settings", kind: "provider", name: "deepseek", status: "same", summary: "openai https://api.deepseek.com", recommended: false },
|
||||
{ id: "hook:PreToolUse#1", category: "automation", kind: "hook", name: "PreToolUse#1", status: "new", consent: "executes", summary: "npx prettier --check .", details: ["event: PreToolUse", "match: edit_file"], recommended: false },
|
||||
{ id: "memory:docs/REASONIX.md", category: "memory", kind: "memory", name: "docs/REASONIX.md", status: "changed", summary: "REASONIX.md", content: "回答保持简洁。\n提交信息用英文。", recommended: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async applyBackup(_planId: string, items: string[]): Promise<BackupApplyResult> {
|
||||
return { applied: items };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { ProgressWatchSettings } from "./boundary";
|
||||
import { MockShell } from "./mock_shell";
|
||||
|
||||
// The boundary half of the fixture: what the agent is refused outright, and how
|
||||
@@ -14,6 +15,7 @@ function effectiveBash(s: SandboxSettings): string {
|
||||
}
|
||||
|
||||
export class MockBoundary extends MockShell {
|
||||
async decideWorkspaceTrust(_trust: "trusted" | "declined") {}
|
||||
// One interrupted barrier and one that a later turn took over, so the card
|
||||
// and the reason it disappears are both reachable without a crash.
|
||||
private adjudicated: Adjudications = {
|
||||
@@ -78,6 +80,11 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.rules };
|
||||
}
|
||||
|
||||
async revokeRememberedProjectRule(rule: string): Promise<PermissionRules> {
|
||||
this.rules = { ...this.rules, remembered: (this.rules.remembered ?? []).filter((saved) => saved !== rule) };
|
||||
return { ...this.rules };
|
||||
}
|
||||
|
||||
async sandbox(): Promise<SandboxSettings> {
|
||||
return { ...this.jail };
|
||||
}
|
||||
@@ -97,6 +104,21 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.browser };
|
||||
}
|
||||
|
||||
private watch: ProgressWatchSettings = {
|
||||
pause: false, rounds: 20, tokenMultiple: 8, defaultRounds: 20, defaultTokenMultiple: 8,
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
};
|
||||
|
||||
async progressWatch(): Promise<ProgressWatchSettings> {
|
||||
return { ...this.watch };
|
||||
}
|
||||
|
||||
async saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings> {
|
||||
if (s.rounds < 1 || s.rounds > 1000 || s.tokenMultiple < 1 || s.tokenMultiple > 1000) throw new Error("progress watch setting out of range");
|
||||
this.watch = { ...this.watch, ...s };
|
||||
return { ...this.watch };
|
||||
}
|
||||
|
||||
// The fixture carries the broken file, because a banner nobody can reach is
|
||||
// a banner nobody designs. Repairing it here clears it, so the whole
|
||||
// interaction is reachable without a Go build.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { download } from "./download";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry } from "./port";
|
||||
import { MockLook } from "./mock_look";
|
||||
import { MockMarket } from "./mock_market";
|
||||
|
||||
// The extension half of the fixture: skills, and the packages that bring them.
|
||||
// MockPort extends this rather than holding one, so the class still satisfies
|
||||
// AgentPort in a single declaration and the settings pane's whole extension
|
||||
// tab can be developed with no Go process at all.
|
||||
export class MockExtensions extends MockLook {
|
||||
export class MockExtensions extends MockMarket {
|
||||
private skillList: SkillEntry[] = [
|
||||
{
|
||||
name: "review", slashName: "review", description: "复核这一轮改动,给出严重度分级",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockLook } from "./mock_look";
|
||||
|
||||
const DIGEST = "sha256:5f0c1e9a7b3d2c4e6f8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e";
|
||||
|
||||
// Four rows that each read differently: pinned and installable, one source
|
||||
// that expands into several skills, one already installed, and one whose
|
||||
// approved version carries no digest and so installs only on the person's trust.
|
||||
const PACKAGES: (MarketPackage & { source: string; pinned: boolean })[] = [
|
||||
{
|
||||
kind: "skill", handle: "nanfei892", name: "make-ui-not-ai", slug: "nanfei892/make-ui-not-ai",
|
||||
summary: "前端界面技能:从真实产品任务出发,设计并验证可用、完整、有辨识度的界面。",
|
||||
description: "适合需要从零搭一个页面、或者把现有页面改得不像模板的时候。\n\n它会先问清楚这个页面给谁用、要完成什么,再给出布局与验证步骤。",
|
||||
homepage: "", repoUrl: "https://github.com/nanfei892/ship-it-skills", tags: ["frontend", "ui-design"],
|
||||
latestVersion: "1.0.0", installCount: 2118, starCount: 41, upCount: 41, downCount: 3, approvalRate: 41 / 44, score: 0.83, verified: true, status: "active", updatedAt: "2026-09-04T02:56:03Z",
|
||||
source: "https://github.com/nanfei892/ship-it-skills/tree/3f1c2e7a9b0d4c6e8f1a2b3c4d5e6f7a8b9c0d1e/make-ui-not-ai", pinned: true,
|
||||
},
|
||||
{
|
||||
kind: "plugin", handle: "acme", name: "review-kit", slug: "acme/review-kit",
|
||||
summary: "代码评审套件:按改动范围逐块评审,只标出会出事的地方。",
|
||||
description: "三个技能 + 一个 /pr 命令。不启动任何进程,不注册钩子。",
|
||||
homepage: "", repoUrl: "https://github.com/acme/review-kit", tags: ["review", "git"],
|
||||
latestVersion: "1.4.0", installCount: 986, starCount: 12, upCount: 12, downCount: 2, approvalRate: 12 / 14, score: 0.61, verified: false, status: "active", updatedAt: "2026-09-20T10:00:00Z",
|
||||
source: "https://github.com/acme/review-kit/tree/9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b", pinned: true,
|
||||
},
|
||||
{
|
||||
kind: "mcp", handle: "irmia", name: "irmia-devkit", slug: "irmia/irmia-devkit",
|
||||
summary: "一组对模型友好的开发工具:批量读写、结构化搜索、依赖图。",
|
||||
description: "", homepage: "", repoUrl: "https://github.com/irmia2026/irmia_devkit_mcp", tags: ["tool", "coding"],
|
||||
latestVersion: "2.7.0", installCount: 1527, starCount: 5, upCount: 5, downCount: 1, approvalRate: 5 / 6, score: 0.52, verified: false, status: "active", updatedAt: "2026-07-22T12:11:46Z",
|
||||
installed: { version: "2.7.0", contentHash: DIGEST },
|
||||
source: "irmia-devkit-mcp", pinned: true,
|
||||
},
|
||||
{
|
||||
kind: "skill", handle: "1574022644", name: "lm-studio-vision-bridge", slug: "1574022644/lm-studio-vision-bridge",
|
||||
summary: "通过本地 LM Studio 视觉模型为 agent 提供图片识别能力。",
|
||||
description: "", homepage: "", repoUrl: "https://github.com/FuchaZ/lm-studio-vision-bridge", tags: ["vision", "local"],
|
||||
latestVersion: "2.0.0", installCount: 1678, starCount: 0, upCount: 0, downCount: 0, approvalRate: null, score: 0.29, verified: false, status: "active", updatedAt: "2026-07-28T07:14:25Z",
|
||||
source: "https://github.com/FuchaZ/lm-studio-vision-bridge/blob/master/SKILL.md", pinned: false,
|
||||
},
|
||||
{
|
||||
kind: "theme", handle: "lumen", name: "dusk-harbor", slug: "lumen/dusk-harbor",
|
||||
summary: "低饱和的暮色主题:浅色、深色各一套,只含主题,不带任何技能或钩子。",
|
||||
description: "", homepage: "", repoUrl: "https://github.com/lumen/reasonix-themes", tags: ["theme", "dark"],
|
||||
latestVersion: "1.2.0", installCount: 412, starCount: 5, upCount: 5, downCount: 0, approvalRate: 1, score: 0.44, verified: false, status: "active", updatedAt: "2026-09-18T08:00:00Z",
|
||||
source: "https://github.com/lumen/reasonix-themes/tree/7d3e1c2b9a8f7e6d5c4b3a291807f6e5d4c3b2a1/dusk-harbor", pinned: true,
|
||||
},
|
||||
];
|
||||
|
||||
// What the demo account has submitted, one row per review state a publisher sees.
|
||||
const MINE: MarketPackage[] = [
|
||||
{ ...PACKAGES[0], handle: "demo", slug: "demo/ship-notes", name: "ship-notes", summary: "发布说明整理技能。", status: "pending", installCount: 0, latestVersion: "0.1.0" },
|
||||
{ ...PACKAGES[4], handle: "demo", slug: "demo/paper-light", name: "paper-light", summary: "纸面质感的浅色主题。", status: "active", installCount: 37, latestVersion: "1.0.1" },
|
||||
{ ...PACKAGES[1], handle: "demo", slug: "demo/lint-kit", name: "lint-kit", summary: "代码检查插件。", status: "rejected", installCount: 0, latestVersion: "0.2.0" },
|
||||
{ ...PACKAGES[4], handle: "demo", slug: "demo/night-desk", name: "night-desk", summary: "只给自己用的深色主题。", status: "private", installCount: 0, latestVersion: "0.1.0" },
|
||||
].map(({ source: _source, pinned: _pinned, ...rest }) => ({ ...rest, installed: undefined }));
|
||||
|
||||
export class MockMarket extends MockLook {
|
||||
private marketInstalled = new Set<string>(["irmia/irmia-devkit"]);
|
||||
private mine = [...MINE];
|
||||
private marketVotes = new Map<string, -1 | 0 | 1>([["acme/review-kit", 1]]);
|
||||
// Voting reads its own sign-in answer so a test can draw the signed-out entry.
|
||||
marketSignedIn = true;
|
||||
|
||||
async marketList(q: MarketQuery): Promise<MarketList> {
|
||||
const needle = (q.q ?? "").trim().toLowerCase();
|
||||
const rows = PACKAGES.filter((p) => (!q.kind || p.kind === q.kind) && (!q.pinned || p.pinned) &&
|
||||
(!needle || `${p.name} ${p.summary} ${p.tags.join(" ")}`.toLowerCase().includes(needle)));
|
||||
return { packages: rows.map((p) => this.view(p)), limit: 24, offset: 0 };
|
||||
}
|
||||
|
||||
async marketDetail(slug: string): Promise<MarketDetail> {
|
||||
const p = PACKAGES.find((x) => x.slug === slug);
|
||||
if (!p) throw new Error("market.not_found");
|
||||
const pkg = this.view(p);
|
||||
return {
|
||||
package: pkg, pinned: p.pinned, installed: pkg.installed,
|
||||
approved: { version: p.latestVersion, source: p.source, contentHash: p.pinned ? DIGEST : "", riskLevel: "", createdAt: p.updatedAt },
|
||||
};
|
||||
}
|
||||
|
||||
// review-kit plans as a source that expands into several skills, which is
|
||||
// the confirmation this tab exists to force.
|
||||
async planMarket(req: MarketRequest): Promise<MarketPlan> {
|
||||
const p = PACKAGES.find((x) => x.slug === req.slug)!;
|
||||
if (!p.pinned && !req.trust) throw new HttpError(409, "the approved version is not pinned to reviewed content", { code: "market.unpinned" });
|
||||
const base = { ok: true, status: "planned", applied: false, source: p.source, slug: p.slug, version: p.latestVersion, contentDigest: DIGEST, unreviewed: !p.pinned };
|
||||
if (p.slug === "acme/review-kit") {
|
||||
return {
|
||||
...base, planId: "high:sha256:mock",
|
||||
actions: ["review", "risk", "pr-notes"].map((name) => ({
|
||||
kind: "skill", action: "copy_skill", status: "planned", riskLevel: "high", name,
|
||||
riskReasons: ["one source expands to 3 skills; every one of them is installed"],
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base, planId: "low:sha256:mock",
|
||||
actions: [{
|
||||
kind: p.kind === "theme" ? "plugin" : p.kind,
|
||||
action: p.kind === "theme" ? "install_plugin_package" : "copy_skill",
|
||||
status: "planned", riskLevel: "low", name: p.name,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
async installMarket(req: MarketRequest): Promise<MarketPlan> {
|
||||
const plan = await this.planMarket(req);
|
||||
if (plan.unreviewed && req.digest !== plan.contentDigest) throw new HttpError(400, "an unreviewed install needs the digest of the preview it confirms", { code: "market.unpreviewed" });
|
||||
this.marketInstalled.add(req.slug);
|
||||
return { ...plan, status: "done", applied: true, actions: plan.actions?.map((a) => ({ ...a, status: "done" })) };
|
||||
}
|
||||
|
||||
async publishMarket(sub: MarketSubmission): Promise<MarketPublished> {
|
||||
const pkg: MarketPackage = {
|
||||
kind: sub.kind, handle: "demo", name: sub.name, slug: `demo/${sub.name}`, summary: sub.summary ?? "", description: sub.description ?? "",
|
||||
homepage: "", repoUrl: sub.repoUrl ?? "", tags: sub.tags ?? [], latestVersion: sub.version || "0.1.0", installCount: 0, starCount: 0, upCount: 0, downCount: 0, approvalRate: null, score: 0,
|
||||
verified: false, status: sub.visibility === "private" ? "private" : "pending", updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.mine.unshift(pkg);
|
||||
return { package: pkg, created: true, version: pkg.latestVersion };
|
||||
}
|
||||
|
||||
async myMarket(): Promise<MarketPackage[]> {
|
||||
return [...this.mine];
|
||||
}
|
||||
|
||||
async planOwnMarket(req: MarketOwnRequest): Promise<MarketPlan> {
|
||||
const p = this.mine.find((x) => x.slug === req.slug);
|
||||
if (!p) throw new Error("market.not_yours");
|
||||
return {
|
||||
ok: true, status: "planned", applied: false, slug: p.slug, version: p.latestVersion, contentDigest: DIGEST, unreviewed: true,
|
||||
planId: "low:sha256:own",
|
||||
actions: [{ kind: p.kind === "theme" ? "plugin" : p.kind, action: "install_plugin_package", status: "planned", riskLevel: "low", name: p.name }],
|
||||
};
|
||||
}
|
||||
|
||||
async installOwnMarket(req: MarketOwnRequest): Promise<MarketPlan> {
|
||||
if (req.digest !== DIGEST) throw new Error("market.unpreviewed");
|
||||
const plan = await this.planOwnMarket(req);
|
||||
this.mine = this.mine.map((p) => (p.slug === req.slug ? { ...p, installed: { version: p.latestVersion, contentHash: DIGEST } } : p));
|
||||
return { ...plan, status: "done", applied: true, actions: plan.actions?.map((a) => ({ ...a, status: "done" })) };
|
||||
}
|
||||
|
||||
async submitMarket(slug: string): Promise<MarketPackage> {
|
||||
const i = this.mine.findIndex((x) => x.slug === slug && x.status === "private");
|
||||
if (i < 0) throw new Error("market.not_private");
|
||||
this.mine[i] = { ...this.mine[i]!, status: "pending" };
|
||||
return this.mine[i]!;
|
||||
}
|
||||
|
||||
async marketMyVote(slug: string): Promise<MarketVote> {
|
||||
if (!this.marketSignedIn) return { signedIn: false, value: 0 };
|
||||
return this.tally(slug, this.marketVotes.get(slug) ?? 0);
|
||||
}
|
||||
|
||||
async voteMarket(slug: string, value: -1 | 0 | 1): Promise<MarketVote> {
|
||||
if (!this.marketSignedIn) throw new Error("market.signed_out");
|
||||
this.marketVotes.set(slug, value);
|
||||
return this.tally(slug, value);
|
||||
}
|
||||
|
||||
private tally(slug: string, value: -1 | 0 | 1): MarketVote {
|
||||
const p = PACKAGES.find((x) => x.slug === slug);
|
||||
if (!p) throw new Error("market.not_found");
|
||||
const was = slug === "acme/review-kit" ? 1 : 0;
|
||||
const up = p.upCount - (was === 1 ? 1 : 0) + (value === 1 ? 1 : 0);
|
||||
const down = p.downCount + (value === -1 ? 1 : 0);
|
||||
return { signedIn: true, value, upCount: up, downCount: down, approvalRate: up + down ? up / (up + down) : null, canVote: true, own: false, emailVerified: true };
|
||||
}
|
||||
|
||||
private view(p: (typeof PACKAGES)[number]): MarketPackage {
|
||||
const { source: _source, ...rest } = p;
|
||||
const installed = this.marketInstalled.has(p.slug) ? { version: p.latestVersion, contentHash: DIGEST } : undefined;
|
||||
return { ...rest, installed };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ModelEffort,
|
||||
Protocol,
|
||||
ProviderCheck,
|
||||
ProviderEdit,
|
||||
@@ -42,6 +43,12 @@ export class MockProvider extends MockBoundary {
|
||||
models: ["gpt-4o", "claude-sonnet-4"], default: "gpt-4o",
|
||||
hasKey: true, inUse: false, preset: false, keyEnv: "MYRELAY_API_KEY",
|
||||
visionModels: ["gpt-4o"], canSetVision: true,
|
||||
reasoningProtocol: "openai", supportedEfforts: ["low", "medium", "high"],
|
||||
modelEfforts: { "claude-sonnet-4": { supportedEfforts: ["low", "high", "max"], defaultEffort: "high" } },
|
||||
inheritedEfforts: {
|
||||
"gpt-4o": { supportedEfforts: ["low", "medium", "high"] },
|
||||
"claude-sonnet-4": { supportedEfforts: ["low", "medium", "high"] },
|
||||
},
|
||||
},
|
||||
// The Responses wire is the one that can carry a turn by reference, so it
|
||||
// is the only entry here offering the choice.
|
||||
@@ -115,6 +122,11 @@ export class MockProvider extends MockBoundary {
|
||||
this.sources = this.sources.map((p) => (p.name === name ? { ...p, continuation: mode } : p));
|
||||
}
|
||||
|
||||
async renameProvider(names: string[], displayName: string): Promise<void> {
|
||||
const label = displayName.trim();
|
||||
this.sources = this.sources.map((p) => (names.includes(p.name) ? { ...p, displayName: label || undefined } : p));
|
||||
}
|
||||
|
||||
async setProviderThinking(name: string, on: boolean): Promise<void> {
|
||||
this.sources = this.sources.map((p) => (p.name === name ? { ...p, sendsThinking: on } : p));
|
||||
}
|
||||
@@ -133,6 +145,7 @@ export class MockProvider extends MockBoundary {
|
||||
headers: edit.headers, extraBody: edit.extraBody,
|
||||
reasoningProtocol: edit.reasoningProtocol, supportedEfforts: edit.supportedEfforts,
|
||||
defaultEffort: edit.defaultEffort,
|
||||
modelEfforts: edit.modelEfforts ? mockModelEfforts(edit.modelEfforts) : p.modelEfforts,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
@@ -160,3 +173,9 @@ function relayCatalog(): string[] {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The kernel keeps only models that declare levels; the rest inherit.
|
||||
function mockModelEfforts(sent: Record<string, ModelEffort>): Record<string, ModelEffort> | undefined {
|
||||
const kept = Object.entries(sent).filter(([, v]) => v.supportedEfforts.length > 0);
|
||||
return kept.length ? Object.fromEntries(kept) : undefined;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { StoragePlan, StorageState } from "./storage";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
|
||||
// A C: drive with three gigabytes left and a session store that ate the rest —
|
||||
// the situation the panel exists for, so the fixture shows it rather than a
|
||||
@@ -7,7 +7,14 @@ const VOL = { volume: "C:", volumeFree: 3_100_000_000, volumeTotal: 476_000_000_
|
||||
const ROAMING = "C:/Users/you/AppData/Roaming/reasonix";
|
||||
const LOCAL = "C:/Users/you/AppData/Local/reasonix";
|
||||
|
||||
export function mockStorage(): StorageState {
|
||||
export function mockStorage(query?: StorageQuery): StorageState {
|
||||
const all = mockStorageAll();
|
||||
if (query?.layout) return { ...all, roots: all.roots.map((r) => ({ ...r, bytes: 0, files: 0, pending: true })) };
|
||||
if (query?.root) return { ...all, roots: all.roots.filter((r) => r.id === query.root) };
|
||||
return all;
|
||||
}
|
||||
|
||||
function mockStorageAll(): StorageState {
|
||||
return {
|
||||
editable: true,
|
||||
roots: [
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface ModelPrice {
|
||||
export interface ModelEntry {
|
||||
ref: string;
|
||||
provider: string;
|
||||
// What the user renamed provider to; provider stays the identity.
|
||||
displayName?: string;
|
||||
model: string;
|
||||
kind?: string;
|
||||
active?: boolean;
|
||||
@@ -61,6 +63,17 @@ export interface ModelEntry {
|
||||
preset?: boolean;
|
||||
}
|
||||
|
||||
// An optional way the session's model can run, beside its effort level. The
|
||||
// kernel lists one only for a model that declares it; label and hint are keys
|
||||
// the frontend owns the wording for.
|
||||
export interface ModelMode {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
hintKey: string;
|
||||
costlier: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// A configured provider as the settings panel lists it.
|
||||
// Which model takes which job. Only the roles with a field behind them in the
|
||||
// kernel appear here; image routing joins once it can name its own model
|
||||
|
||||
@@ -5,6 +5,8 @@ export type { Attachment, DroppedRef };
|
||||
|
||||
import type { AccountState, AccountUser, DeviceGrant } from "./account";
|
||||
import type { Adjudications } from "./adjudication";
|
||||
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupPlan } from "./backup";
|
||||
export type * from "./backup";
|
||||
export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adjudication";
|
||||
import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook";
|
||||
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp";
|
||||
@@ -12,9 +14,9 @@ import type { MemoryCatalog, MemoryEdit, MemoryEntry } from "./memory";
|
||||
import type { UsageReport } from "./usage";
|
||||
export type { MemoryEdit } from "./memory";
|
||||
export type { Money, UsageDay, UsageModel, UsageProvider, UsageReport } from "./usage";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelPrice, RoleAssignments } from "./model";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments } from "./model";
|
||||
import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalMode, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry } from "./skill";
|
||||
import type { UpdateProgress, VersionEntry, VersionHub } from "./version";
|
||||
@@ -23,24 +25,26 @@ import type { ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, Wor
|
||||
// The port is one contract; its subjects each keep their own file, the way the
|
||||
// wire and the layers below already do. This is where a reader still finds
|
||||
// them all.
|
||||
export type { AccountState, AccountUser, ApprovalMode, ApprovalVerdict, CapabilityScope,
|
||||
export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, ApprovalVerdict, WorkspaceTrust, CapabilityScope,
|
||||
Checkpoint, CompactionSettings, Completion, CompletionItem, ContextBreakdown, DeviceGrant, HistoryMessage, HostTodo, BrowserTab,
|
||||
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
|
||||
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
|
||||
MemoryEntry, ModelEntry, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
|
||||
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
|
||||
RewindResult, RewindScope, RoleAssignments, ScopeLayer, SessionEntry, SessionStatus,
|
||||
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
|
||||
VersionHub, WalletLine, WalletReading, ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
|
||||
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import type { Appearance, ThemeImport, ThemePack } from "./look";
|
||||
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./boundary";
|
||||
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
|
||||
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
|
||||
export type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
|
||||
import type { StoragePlan, StorageState } from "./storage";
|
||||
export type { ModelEffort, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
|
||||
export type * from "./plugin";
|
||||
export type * from "./market";
|
||||
|
||||
// GET /mcp. One external tool provider. state is ready | connecting | failed |
|
||||
// disabled | idle: disabled is switched off and stays off across restarts, idle
|
||||
@@ -58,6 +62,19 @@ export type * from "./boundary";
|
||||
|
||||
export type * from "./provider";
|
||||
|
||||
/** A skill chip as the kernel takes it; offset only orders chips in a line. */
|
||||
export interface InvocationRequest {
|
||||
name: string;
|
||||
kind: "skill" | "subagent";
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** A line holding chips: submit is what the model reads, chips taken out. */
|
||||
export interface ChipCall {
|
||||
submit: string;
|
||||
invocations: InvocationRequest[];
|
||||
}
|
||||
|
||||
/** What the kernel answers when a line is queued — mid-turn guidance or a whole
|
||||
* turn waiting its go. itemId is the durable entry, and it is the whole reason
|
||||
* the line can be taken back: without it the row on screen has no name to
|
||||
@@ -164,6 +181,24 @@ export interface AgentPort {
|
||||
// it on the way. Installing is the same door: a folder, a link, or this
|
||||
// archive unpacked — there is no separate import.
|
||||
exportPlugin(name: string): Promise<PluginExport>;
|
||||
// The community market: a source like a pasted address, not a trust root.
|
||||
// Install is the same plan-then-apply pair, pinned to the reviewed digest.
|
||||
marketList(q: MarketQuery): Promise<MarketList>;
|
||||
marketDetail(slug: string): Promise<MarketDetail>;
|
||||
planMarket(req: MarketRequest): Promise<MarketPlan>;
|
||||
installMarket(req: MarketRequest): Promise<MarketPlan>;
|
||||
// Publishing spends the account session; both refuse when signed out.
|
||||
publishMarket(sub: MarketSubmission): Promise<MarketPublished>;
|
||||
myMarket(): Promise<MarketPackage[]>;
|
||||
// The account's own package in any state, installed against the digest its
|
||||
// own preview showed; the kernel reads the package from the registry as the
|
||||
// account, never from what the window sends.
|
||||
planOwnMarket(req: MarketOwnRequest): Promise<MarketPlan>;
|
||||
installOwnMarket(req: MarketOwnRequest): Promise<MarketPlan>;
|
||||
submitMarket(slug: string): Promise<MarketPackage>;
|
||||
marketMyVote(slug: string): Promise<MarketVote>;
|
||||
// value 0 withdraws. The kernel sends the account token to the registry.
|
||||
voteMarket(slug: string, value: -1 | 0 | 1): Promise<MarketVote>;
|
||||
// Text the window assembled, put on disk. Returns where it went, or null when
|
||||
// the host handled it without a path to report (a browser download).
|
||||
saveText(name: string, content: string): Promise<string | null>;
|
||||
@@ -207,11 +242,15 @@ export interface AgentPort {
|
||||
savePermissions(lists: PermissionLists): Promise<PermissionRules>;
|
||||
/** Take back what a prompt allowed for this session — one rule, or all of them when rule is "". */
|
||||
revokeSessionGrant(rule: string): Promise<PermissionRules>;
|
||||
revokeRememberedProjectRule(rule: string): Promise<PermissionRules>;
|
||||
sandbox(): Promise<SandboxSettings>;
|
||||
saveSandbox(s: SandboxSettings): Promise<SandboxSettings>;
|
||||
browserTools(): Promise<BrowserToolsSettings>;
|
||||
// Writes the user file, then rebuilds: the tools are bound while assembling.
|
||||
saveBrowserTools(enabled: boolean): Promise<BrowserToolsSettings>;
|
||||
progressWatch(): Promise<ProgressWatchSettings>;
|
||||
// Applies to the running turn at its next round; no rebuild.
|
||||
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
|
||||
// null in a browser tab: there is no window to keep running.
|
||||
trayPrefs(): Promise<TrayPrefs | null>;
|
||||
setTrayPrefs(icon: boolean, closeToTray: boolean): Promise<TrayPrefs | null>;
|
||||
@@ -250,7 +289,7 @@ export interface AgentPort {
|
||||
// Persisted, then the runtime is rebuilt: boot reads every role model while
|
||||
// assembling, so an assignment cannot reach a runtime that is already up.
|
||||
setRole(role: string, ref: string): Promise<void>;
|
||||
storage(): Promise<StorageState>;
|
||||
storage(query?: StorageQuery): Promise<StorageState>;
|
||||
planStorageMove(root: string, dir: string): Promise<StoragePlan>;
|
||||
moveStorage(root: string, dir: string): Promise<StoragePlan>;
|
||||
providers(): Promise<ProviderEntry[]>;
|
||||
@@ -272,6 +311,8 @@ export interface AgentPort {
|
||||
setProviderWebSearch(name: string, on: boolean): Promise<void>;
|
||||
setProviderThinking(name: string, on: boolean): Promise<void>;
|
||||
setProviderContinuation(name: string, mode: string): Promise<void>;
|
||||
// Relabels the entries shown as one account; "" puts the derived name back.
|
||||
renameProvider(names: string[], displayName: string): Promise<void>;
|
||||
|
||||
// Whether the opening sequence still owes this machine a showing, and the
|
||||
// acknowledgement that closes it out.
|
||||
@@ -285,6 +326,10 @@ export interface AgentPort {
|
||||
// one. Resolves only on failure: a success ends with the process handing over
|
||||
// to the build it just installed.
|
||||
goToVersion(version: string): Promise<void>;
|
||||
// Installs the release goToVersion left verified and waiting, and restarts
|
||||
// into it. Refused with update.restart_busy while work runs here, unless
|
||||
// force says the person has seen that.
|
||||
restartToVersion(version: string, force: boolean): Promise<void>;
|
||||
// Returns an unsubscribe. A browser tab has no shell to report progress, so
|
||||
// it never fires there.
|
||||
onUpdateProgress(cb: (p: UpdateProgress) => void): () => void;
|
||||
@@ -297,6 +342,12 @@ export interface AgentPort {
|
||||
accountLogin(): Promise<DeviceGrant>;
|
||||
accountPoll(deviceCode: string): Promise<{ status: "pending" | "complete"; slowDown?: boolean }>;
|
||||
accountLogout(): Promise<void>;
|
||||
// Backups exist only for a signed-in account; every call refuses otherwise.
|
||||
backups(): Promise<BackupCatalog>;
|
||||
createBackup(req: BackupCreateRequest): Promise<BackupCreated>;
|
||||
deleteBackup(id: string): Promise<void>;
|
||||
previewBackup(id: string, passphrase: string): Promise<BackupPlan>;
|
||||
applyBackup(planId: string, items: string[], consented: string[]): Promise<BackupApplyResult>;
|
||||
workspaces(): Promise<WorkspaceInfo>;
|
||||
// Rebuilds the whole runtime against another folder. The conversation does
|
||||
// not come along, so the caller has to reload the transcript afterwards.
|
||||
@@ -364,7 +415,8 @@ export interface AgentPort {
|
||||
// only this says how, and asking per path is what keeps a session that
|
||||
// touched two hundred files from shipping two hundred diffs nobody opened.
|
||||
changeDiff(path: string): Promise<ChangeDiff>;
|
||||
workspaceFiles(path?: string, query?: string): Promise<WorkspaceFiles>;
|
||||
// Dot entries are left out unless hidden is set; VCS stores never appear.
|
||||
workspaceFiles(path?: string, query?: string, hidden?: boolean): Promise<WorkspaceFiles>;
|
||||
workspaceFile(path: string): Promise<WorkspaceFile>;
|
||||
saveWorkspaceFile(file: WorkspaceFile): Promise<WorkspaceFile>;
|
||||
// Where an image in the workspace is served, for a rendered document to show.
|
||||
@@ -389,7 +441,9 @@ export interface AgentPort {
|
||||
// of their watermarks, because a duplicate folds and a gap does not.
|
||||
subscribe(onEvent: (ev: WireEvent) => void, onGap?: () => void, bootstrap?: () => Promise<number>): () => void;
|
||||
|
||||
submit(text: string): Promise<void>;
|
||||
// chips carries skill chips beside the line; without it the line is parsed
|
||||
// as typed, so a kernel that predates chips still runs a leading one.
|
||||
submit(text: string, chips?: ChipCall): Promise<void>;
|
||||
// /submit 409s once a turn holds the session. Mid-turn input is durable and
|
||||
// goes through the inbox, which delivers it at the next tool boundary. The
|
||||
// receipt is what makes it cancellable while it waits there.
|
||||
@@ -397,7 +451,7 @@ export interface AgentPort {
|
||||
// A whole turn, queued because one is already running. The kernel
|
||||
// refuses /submit with a code rather than a sentence, so this is what
|
||||
// the client does about it instead of showing anyone the refusal.
|
||||
queueFollowup(text: string): Promise<Queued>;
|
||||
queueFollowup(text: string, chips?: ChipCall): Promise<Queued>;
|
||||
// Takes a queued line back before the turn reads it, and refuses once it has.
|
||||
cancelQueued(itemId: string): Promise<void>;
|
||||
// The queue as the kernel holds it. Read on every inbox_changed frame: the
|
||||
@@ -472,9 +526,13 @@ export interface AgentPort {
|
||||
|
||||
setPlanMode(on: boolean): Promise<void>;
|
||||
setApprovalMode(mode: ApprovalMode): Promise<void>;
|
||||
// The person's answer to "trust this folder?" for the session's workspace.
|
||||
decideWorkspaceTrust(trust: "trusted" | "declined"): Promise<void>;
|
||||
setPreset(preset: Preset): Promise<void>;
|
||||
setModel(ref: string): Promise<void>;
|
||||
setEffort(effort: string): Promise<void>;
|
||||
// "" turns the session's model mode off.
|
||||
setModelMode(mode: string): Promise<void>;
|
||||
compaction(): Promise<CompactionSettings>;
|
||||
saveCompaction(softLimitTokens: number): Promise<CompactionSettings>;
|
||||
setGoal(text: string): Promise<void>;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
export interface ProviderEntry {
|
||||
name: string;
|
||||
// The label a person gave this source. Only ever shown: every ref, the
|
||||
// default model and saved sessions keep pointing at name.
|
||||
displayName?: string;
|
||||
kind: string;
|
||||
baseUrl: string;
|
||||
models: string[];
|
||||
@@ -40,6 +43,12 @@ export interface ProviderEntry {
|
||||
// level auto resolves to. Absent is no declaration.
|
||||
supportedEfforts?: string[];
|
||||
defaultEffort?: string;
|
||||
// Per model, for a relay serving several vendors under one key: what a model
|
||||
// declares for itself, the ladder it inherits when it declares nothing, and
|
||||
// a reasoning protocol it declares for itself.
|
||||
modelEfforts?: Record<string, ModelEffort>;
|
||||
inheritedEfforts?: Record<string, ModelEffort>;
|
||||
modelProtocols?: Record<string, string>;
|
||||
// Removing the one in use would leave the session on a model that no longer
|
||||
// resolves, so the row offers no delete.
|
||||
inUse: boolean;
|
||||
@@ -164,6 +173,16 @@ export interface ProviderEdit {
|
||||
// An empty list clears the declared vocabulary; omitted leaves it alone.
|
||||
supportedEfforts?: string[];
|
||||
defaultEffort?: string;
|
||||
// The whole per-model answer for the listed models: a model left out
|
||||
// inherits the connection's levels again. Omitted leaves every model alone.
|
||||
modelEfforts?: Record<string, ModelEffort>;
|
||||
}
|
||||
|
||||
// One model's effort vocabulary and the level auto resolves to; no default is
|
||||
// the first level.
|
||||
export interface ModelEffort {
|
||||
supportedEfforts: string[];
|
||||
defaultEffort?: string;
|
||||
}
|
||||
|
||||
// What the panel sends back after the user has looked at the probe.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ModelMode } from "./model";
|
||||
import type { Via } from "./wire";
|
||||
// A conversation and the states it passes through: what is running, what it
|
||||
// cost, and the points it can be wound back to.
|
||||
@@ -30,6 +31,9 @@ export interface HistoryMessage {
|
||||
// rebuilt card has only the words, which a tool's own output can imitate.
|
||||
toolFailed?: boolean;
|
||||
toolRefusalCode?: string;
|
||||
// outputDiff marks a rebuilt shell result whose whole output is a unified
|
||||
// diff, re-derived by the host so a reopened transcript renders it as one.
|
||||
outputDiff?: boolean;
|
||||
}
|
||||
|
||||
// One call inside a history message. resolvedName/capabilityId say what a
|
||||
@@ -134,6 +138,17 @@ export interface RewindConflict {
|
||||
|
||||
export type ApprovalMode = "ask" | "auto" | "dontAsk" | "yolo";
|
||||
|
||||
export type WorkspaceTrust = "" | "trusted" | "declined";
|
||||
|
||||
/** Where the session's posture came from. A defaulted session opens in auto
|
||||
* only when the OS sandbox confines writes and the folder is trusted. */
|
||||
export interface ApprovalDefault {
|
||||
defaulted: boolean;
|
||||
writesConfined: boolean;
|
||||
trust: WorkspaceTrust;
|
||||
trustable: boolean;
|
||||
}
|
||||
|
||||
// "light" was retired into balanced: its only enforced differences were two
|
||||
// sub-agent switches, and a setting that costs a choice without changing what
|
||||
// it names is a question not worth asking. Old sessions still send it.
|
||||
@@ -210,8 +225,14 @@ export interface SessionStatus {
|
||||
decisions?: Decision[];
|
||||
preset: Preset;
|
||||
effort?: string;
|
||||
// Absent unless the session's model declares a mode: its switch is not drawn.
|
||||
modes?: ModelMode[];
|
||||
modelRef?: string;
|
||||
// The label the user gave modelRef's provider; absent when none is set.
|
||||
providerDisplayName?: string;
|
||||
toolApprovalMode: ApprovalMode;
|
||||
// Absent from a kernel that predates it; drawn only where it is present.
|
||||
approvalDefault?: ApprovalDefault;
|
||||
autoApproveTools: boolean;
|
||||
bypass: boolean;
|
||||
goal: string;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, Appearance, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
|
||||
import { SseTheme } from "./sse_theme";
|
||||
import type { StoragePlan, StorageState } from "./storage";
|
||||
import { SseBackup } from "./sse_backup";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { host } from "./host";
|
||||
import { current } from "../i18n";
|
||||
import { openLiveStream } from "./livestream";
|
||||
|
||||
// The running project is the default, so its requests stay the bare path they
|
||||
// have always been and only a cross-project read carries the folder.
|
||||
@@ -15,7 +16,7 @@ import { current } from "../i18n";
|
||||
// a load. The read is one small JSON body and answers from memory.
|
||||
const UPDATE_POLL_MS = 500;
|
||||
|
||||
export class SsePort extends SseTheme implements AgentPort {
|
||||
export class SsePort extends SseBackup implements AgentPort {
|
||||
status() {
|
||||
return this.get<SessionStatus>("/status");
|
||||
}
|
||||
@@ -178,8 +179,9 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
return this.post("/roles", { role, ref });
|
||||
}
|
||||
|
||||
storage() {
|
||||
return this.get<StorageState>("/storage");
|
||||
storage(query?: StorageQuery) {
|
||||
const q = query?.layout ? "?layout=1" : query?.root ? "?root=" + encodeURIComponent(query.root) : "";
|
||||
return this.get<StorageState>("/storage" + q);
|
||||
}
|
||||
|
||||
// Both answer with a plan: a refused move is reported in the body rather
|
||||
@@ -241,6 +243,16 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
if (!res.ok) await SsePort.fail("/update/install", res);
|
||||
}
|
||||
|
||||
async restartToVersion(version: string, force: boolean): Promise<void> {
|
||||
const res = await fetch("/update/restart", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ version, force }),
|
||||
});
|
||||
if (!res.ok) await SsePort.fail("/update/restart", res);
|
||||
}
|
||||
|
||||
// Failure is silent on purpose. Nothing the user asked for is happening here,
|
||||
// there is nothing for them to do about it, and the two ordinary answers are
|
||||
// both non-events: a kernel with no update capability has no route, and a
|
||||
@@ -420,10 +432,11 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
return this.get<ChangeDiff>(`/changes/diff?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
workspaceFiles(path = "", query = "") {
|
||||
workspaceFiles(path = "", query = "", hidden = false) {
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set("path", path);
|
||||
if (query) params.set("q", query);
|
||||
if (hidden) params.set("hidden", "1");
|
||||
return this.get<WorkspaceFiles>(`/workspace/files${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -615,10 +628,12 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
};
|
||||
// EventSource resumes on its own: it reconnects carrying the last id it
|
||||
// saw, and the server replays from there. Recovery here is for the frames
|
||||
// shed without the connection ever dropping.
|
||||
const es = new EventSource(this.base + "/events", { withCredentials: true });
|
||||
es.onmessage = (m) => feed(m.data);
|
||||
const detach = () => es.close();
|
||||
// shed without the connection ever dropping, and a replacement stream for
|
||||
// one that went silent without dropping resumes from the same cursor.
|
||||
const detach = openLiveStream(
|
||||
() => this.base + "/events" + (seen === null ? "" : `?lastEventId=${seen}`),
|
||||
feed,
|
||||
);
|
||||
if (bootstrap) void bootstrapFrom(bootstrap);
|
||||
return () => {
|
||||
live = false;
|
||||
@@ -626,14 +641,14 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
};
|
||||
}
|
||||
|
||||
submit(text: string) {
|
||||
return this.post("/submit", { input: text });
|
||||
submit(text: string, chips?: ChipCall) {
|
||||
return this.post("/submit", { input: text, ...chips });
|
||||
}
|
||||
steer(text: string) {
|
||||
return this.post0<Queued>("/inbox/items", { input: text, intent: "steer" });
|
||||
}
|
||||
queueFollowup(text: string) {
|
||||
return this.post0<Queued>("/inbox/items", { input: text, intent: "followup" });
|
||||
queueFollowup(text: string, chips?: ChipCall) {
|
||||
return this.post0<Queued>("/inbox/items", { input: text, intent: "followup", ...chips });
|
||||
}
|
||||
async cancelQueued(itemId: string) {
|
||||
await this.del("/inbox/items/" + encodeURIComponent(itemId));
|
||||
@@ -732,6 +747,9 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
setApprovalMode(mode: ApprovalMode) {
|
||||
return this.post("/tool-approval-mode", { mode });
|
||||
}
|
||||
decideWorkspaceTrust(trust: "trusted" | "declined") {
|
||||
return this.post("/workspace-trust", { trust });
|
||||
}
|
||||
setPreset(preset: Preset) {
|
||||
return this.post("/preset", { preset });
|
||||
}
|
||||
@@ -741,6 +759,9 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
setEffort(effort: string) {
|
||||
return this.post("/effort", { effort });
|
||||
}
|
||||
setModelMode(mode: string) {
|
||||
return this.post("/model-mode", { mode });
|
||||
}
|
||||
compaction() {
|
||||
return this.get<CompactionSettings>("/compaction");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SseTheme } from "./sse_theme";
|
||||
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupPlan } from "./backup";
|
||||
|
||||
// Backups kept in the signed-in account. Every call is refused while signed
|
||||
// out, so a caller shows the section only when the account says signed in.
|
||||
export class SseBackup extends SseTheme {
|
||||
backups() {
|
||||
return this.get<BackupCatalog>("/backups");
|
||||
}
|
||||
createBackup(req: BackupCreateRequest) {
|
||||
return this.post0<BackupCreated>("/backups", req);
|
||||
}
|
||||
deleteBackup(id: string) {
|
||||
return this.del("/backups/" + encodeURIComponent(id));
|
||||
}
|
||||
previewBackup(id: string, passphrase: string) {
|
||||
return this.post0<BackupPlan>("/backups/" + encodeURIComponent(id) + "/preview", { passphrase });
|
||||
}
|
||||
applyBackup(planId: string, items: string[], consented: string[]) {
|
||||
return this.post0<BackupApplyResult>("/backups/apply", { planId, items, consented });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SseShell } from "./sse_shell";
|
||||
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { ProgressWatchSettings } from "./boundary";
|
||||
|
||||
// Where the agent may reach: the permission rules a call is matched against and
|
||||
// the sandbox the shell runs in.
|
||||
@@ -16,6 +17,9 @@ export class SseBoundary extends SseShell {
|
||||
revokeSessionGrant(rule: string) {
|
||||
return this.post0<PermissionRules>("/permissions/revoke", { rule });
|
||||
}
|
||||
revokeRememberedProjectRule(rule: string) {
|
||||
return this.post0<PermissionRules>("/permissions/remembered/revoke", { rule });
|
||||
}
|
||||
sandbox() {
|
||||
return this.get<SandboxSettings>("/sandbox");
|
||||
}
|
||||
@@ -28,6 +32,12 @@ export class SseBoundary extends SseShell {
|
||||
saveBrowserTools(enabled: boolean) {
|
||||
return this.post0<BrowserToolsSettings>("/browser-tools", { enabled });
|
||||
}
|
||||
progressWatch() {
|
||||
return this.get<ProgressWatchSettings>("/progress-watch");
|
||||
}
|
||||
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">) {
|
||||
return this.post0<ProgressWatchSettings>("/progress-watch", s);
|
||||
}
|
||||
configProblem() {
|
||||
return this.get<ConfigProblem | null>("/config/problem");
|
||||
}
|
||||
|
||||
Loaded 100 of 1037 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user