Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
191bae64e5 | ||
|
|
19ca99e863 | ||
|
|
65addc2c4f | ||
|
|
a7c778e3ba | ||
|
|
3fc4e413d2 | ||
|
|
ca6ede33e1 | ||
|
|
3e89edf830 | ||
|
|
3ec1e82629 | ||
|
|
9f9ab13a64 | ||
|
|
29b8cacfab | ||
|
|
e117478eb3 | ||
|
|
f0b7cf3836 | ||
|
|
7beca22bc0 | ||
|
|
2a25d53ee5 | ||
|
|
39230d1738 | ||
|
|
9f7a3cb1f6 | ||
|
|
a1a4c8ce1f | ||
|
|
10dc1571bc | ||
|
|
2fc3f9c1ff | ||
|
|
fe002eb124 | ||
|
|
ed8ce1f96c | ||
|
|
5d691374c3 | ||
|
|
7577834206 | ||
|
|
7a44a8fdec | ||
|
|
47d56b802a | ||
|
|
79061f447c | ||
|
|
a6857faf48 | ||
|
|
b034fa7026 | ||
|
|
c3aa81be2f | ||
|
|
727025a2f3 | ||
|
|
c5794c505e | ||
|
|
955c5b73c5 | ||
|
|
74c69dc8ed | ||
|
|
c008f41bb9 | ||
|
|
ef61436967 | ||
|
|
999d63b517 | ||
|
|
56bec611e5 | ||
|
|
2762555038 | ||
|
|
6800ec9d66 | ||
|
|
7c98c65163 | ||
|
|
0fac4cd8e9 | ||
|
|
6016997a72 | ||
|
|
92025df393 | ||
|
|
66c097ab78 | ||
|
|
f382ff84f7 | ||
|
|
3c408684ea | ||
|
|
1c557bb98a | ||
|
|
47c47d6a03 | ||
|
|
d6c76cfbfa | ||
|
|
bffc098d09 | ||
|
|
1b8f1504b3 | ||
|
|
ec2cb3ab47 | ||
|
|
4527943e91 | ||
|
|
5265b3002c | ||
|
|
5d41ad710c | ||
|
|
7b3f3047ab | ||
|
|
f0d22e4b6c | ||
|
|
c707165212 | ||
|
|
c44de99854 | ||
|
|
6ce160a5a0 | ||
|
|
af7dceaf77 | ||
|
|
5ce827cac9 | ||
|
|
55624e10b9 | ||
|
|
c4622a1d5b | ||
|
|
caed552de1 | ||
|
|
da52ffea14 | ||
|
|
7fe1cb384e | ||
|
|
f6d1fd511c | ||
|
|
6f7ee72be5 | ||
|
|
2627933f33 | ||
|
|
8f29c9f4e3 | ||
|
|
915f1bf1bc | ||
|
|
ffc10cc659 | ||
|
|
104ffeae23 | ||
|
|
021ee34546 | ||
|
|
0512f06a6a | ||
|
|
1215fbbd76 | ||
|
|
77763f00fb | ||
|
|
7fdae5d22a | ||
|
|
7f7a40381e | ||
|
|
3615545bca | ||
|
|
af250d8494 | ||
|
|
d2b2be0f01 | ||
|
|
d0e9a42cec | ||
|
|
67f4e1b4a9 | ||
|
|
94a7705bdd | ||
|
|
bd740f203b | ||
|
|
d921005e25 | ||
|
|
d48bf743f2 | ||
|
|
f96b2e6ef7 | ||
|
|
ac705b52c9 | ||
|
|
b51d365ef0 | ||
|
|
f813c7ddad | ||
|
|
3510b18814 | ||
|
|
dfeedf613d | ||
|
|
b03c94dbed | ||
|
|
2bd721cebc | ||
|
|
98b4562947 | ||
|
|
deae8e3b4d | ||
|
|
e4ea0a0ed7 | ||
|
|
a10081f83b | ||
|
|
402969670d | ||
|
|
8a5f8379ed | ||
|
|
902379ea3e | ||
|
|
80b58ec71d | ||
|
|
0709714a6c | ||
|
|
8030b01a2a | ||
|
|
e6c42b5d80 | ||
|
|
5d524d0427 | ||
|
|
ba7e5b052a | ||
|
|
f2fcf89c1f | ||
|
|
8c288760d0 | ||
|
|
acb3bde097 | ||
|
|
c5e841ab0e |
@@ -115,6 +115,10 @@
|
||||
# HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL
|
||||
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
|
||||
|
||||
# DeepInfra — 100+ top open models, pay-per-use.
|
||||
# Get your key at: https://deepinfra.com/dash/api_keys
|
||||
# DEEPINFRA_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (Qwen OAuth)
|
||||
# =============================================================================
|
||||
|
||||
@@ -10,7 +10,7 @@ outputs:
|
||||
description: Run Python tests / ruff / ty / windows-footguns.
|
||||
value: ${{ steps.classify.outputs.python }}
|
||||
frontend:
|
||||
description: Run the TypeScript typecheck matrix + desktop build.
|
||||
description: Run the TypeScript testing matrix + desktop build.
|
||||
value: ${{ steps.classify.outputs.frontend }}
|
||||
docker_meta:
|
||||
description: Docker setup and meta files have changed.
|
||||
|
||||
@@ -70,11 +70,11 @@ jobs:
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
|
||||
typecheck:
|
||||
name: TypeScript
|
||||
js-tests:
|
||||
name: JS & TS checks
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
uses: ./.github/workflows/js-tests.yml
|
||||
|
||||
docs-site:
|
||||
name: Docs Site
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
needs:
|
||||
- tests
|
||||
- lint
|
||||
- typecheck
|
||||
- js-tests
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
@@ -154,14 +154,18 @@ jobs:
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
env:
|
||||
RESULTS: ${{ toJSON(needs.*.result) }}
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
echo "$RESULTS" | python3 -c "
|
||||
echo "$NEEDS" | python3 -c "
|
||||
import json, sys
|
||||
results = json.load(sys.stdin)
|
||||
failed = [r for r in results if r == 'failure']
|
||||
needs = json.load(sys.stdin)
|
||||
failed = [name for name, info in needs.items() if info['result'] == 'failure']
|
||||
for name, info in sorted(needs.items()):
|
||||
result = info['result']
|
||||
icon = '✅' if result in ('success', 'skipped') else '❌'
|
||||
print(f'{icon} {name}: {result}')
|
||||
if failed:
|
||||
print(f'::error::{len(failed)} job(s) failed')
|
||||
print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}')
|
||||
sys.exit(1)
|
||||
print('All checks passed (or were skipped)')
|
||||
"
|
||||
|
||||
@@ -143,9 +143,70 @@ jobs:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
# Profile every docker subprocess call so we can diagnose why
|
||||
# the suite is slow on CI vs. local. Each per-file subprocess
|
||||
# writes its own docker-test-profile-<pid>.json; the merge
|
||||
# step below combines them into a single report.
|
||||
HERMES_DOCKER_TEST_PROFILE: "1"
|
||||
run: |
|
||||
scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
|
||||
- name: Merge docker test profiles
|
||||
if: always()
|
||||
run: |
|
||||
python3 -c "
|
||||
import json, glob, sys
|
||||
files = sorted(glob.glob('docker-test-profile-*.json'))
|
||||
if not files:
|
||||
print('No per-PID profile files found — profiling may not have activated')
|
||||
sys.exit(0)
|
||||
merged = {'tests': [], 'summary': {}}
|
||||
for f in files:
|
||||
with open(f) as fh:
|
||||
data = json.load(fh)
|
||||
merged['tests'].extend(data.get('tests', []))
|
||||
# Recompute summary aggregates from merged tests.
|
||||
subcmd_totals = {}
|
||||
subcmd_counts = {}
|
||||
total_sleep = 0
|
||||
total_sleeps = 0
|
||||
for t in merged['tests']:
|
||||
for sub, info in t.get('by_subcommand', {}).items():
|
||||
subcmd_totals[sub] = subcmd_totals.get(sub, 0) + info['total_s']
|
||||
subcmd_counts[sub] = subcmd_counts.get(sub, 0) + info['count']
|
||||
total_sleep += t.get('total_sleep_s', 0)
|
||||
total_sleeps += t.get('sleep_count', 0)
|
||||
total_docker = sum(subcmd_totals.values())
|
||||
merged['summary'] = {
|
||||
'total_tests': len(merged['tests']),
|
||||
'total_calls': sum(subcmd_counts.values()),
|
||||
'total_sleeps': total_sleeps,
|
||||
'total_docker_s': round(total_docker, 3),
|
||||
'total_sleep_s': round(total_sleep, 3),
|
||||
'total_wall_s': round(total_docker + total_sleep, 3),
|
||||
'by_subcommand': {
|
||||
sub: {
|
||||
'count': subcmd_counts[sub],
|
||||
'total_s': round(subcmd_totals[sub], 3),
|
||||
'avg_s': round(subcmd_totals[sub] / subcmd_counts[sub], 3) if subcmd_counts[sub] else 0,
|
||||
}
|
||||
for sub in sorted(subcmd_totals, key=lambda s: subcmd_totals[s], reverse=True)
|
||||
},
|
||||
}
|
||||
with open('docker-test-profile.json', 'w') as fh:
|
||||
json.dump(merged, fh, indent=2)
|
||||
print(f'Merged {len(files)} per-PID profiles ({len(merged[\"tests\"])} tests, {total_docker:.0f}s docker + {total_sleep:.0f}s sleep = {total_docker + total_sleep:.0f}s wall)')
|
||||
"
|
||||
|
||||
- name: Upload docker test profile
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: docker-test-profile-${{ matrix.arch }}
|
||||
path: docker-test-profile.json
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitch both per-arch digests into a single tagged multi-arch manifest.
|
||||
# This is a registry-side operation — no building, no layer re-push —
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# .github/workflows/js-tests.yml
|
||||
name: JS Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
workspaces:
|
||||
name: List npm workspaces
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
packages: ${{ steps.set-matrix.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- id: set-matrix
|
||||
run: |
|
||||
PACKAGES=$(npm query .workspace | jq -c '[.[].location]')
|
||||
if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then
|
||||
echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)."
|
||||
exit 1
|
||||
fi
|
||||
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
|
||||
|
||||
check:
|
||||
name: Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: workspaces
|
||||
strategy:
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
# --ignore-scripts: TS & tests don't need native deps
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} check
|
||||
@@ -1,51 +0,0 @@
|
||||
# .github/workflows/typecheck.yml
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Check TypeScript
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package:
|
||||
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
|
||||
# native builds. Skipping install scripts drops node-pty's node-gyp
|
||||
# header fetch — the transient flake that killed this job pre-`tsc` — and
|
||||
# is faster. retry covers the remaining registry blips.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
|
||||
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
|
||||
# which does NOT exercise Vite/Rolldown module resolution — so an
|
||||
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
|
||||
# longer exports "./react-shim") slips past typecheck and only explodes when
|
||||
# users build apps/desktop from source on install/update. Run the real
|
||||
# `vite build` here so that class of break fails in CI instead.
|
||||
desktop-build:
|
||||
name: Build desktop app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# Keep install scripts here: the production build may need node-pty's
|
||||
# native binary. retry handles the transient install-time fetch flakes.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
+1
-1
@@ -69,7 +69,7 @@ hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/release/
|
||||
apps/desktop/*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
|
||||
@@ -1354,3 +1354,58 @@ not the specific names.
|
||||
|
||||
Reviewers should reject new change-detector tests; authors should convert
|
||||
them into invariants before re-requesting review.
|
||||
|
||||
### Never read source code in tests
|
||||
|
||||
A test that reads a source file's text is testing *the shape of the
|
||||
source code*, not its behavior. This is a hard antipattern, banned outright.
|
||||
Any test that reads a .py, .ts, .tsx, etc., file is suspect.
|
||||
|
||||
**Why it's actively harmful, not just weak:**
|
||||
|
||||
- It passes when the implementation is subtly broken (the regex matches a
|
||||
call site that exists but is wired wrong) and fails when a correct
|
||||
refactor changes formatting, variable names, or control flow with
|
||||
identical runtime behavior. Both directions of failure are wrong.
|
||||
- It can't be run against a built/bundled/minified artifact, so it silently
|
||||
stops testing anything the moment code moves, gets renamed, or a
|
||||
dependency reformats it.
|
||||
- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
|
||||
fail during pure structural cleanup with no behavior change, and either
|
||||
hand-wave the failure (dangerous) or waste time updating regexes that add
|
||||
nothing (waste).
|
||||
- It gives false confidence. a green suite full of source-regex tests
|
||||
looks like coverage but has never once executed the code path it claims
|
||||
to guard.
|
||||
|
||||
**Do not write:**
|
||||
|
||||
```ts
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
test('backend spawn hides the Windows console', () => {
|
||||
assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
|
||||
})
|
||||
```
|
||||
|
||||
**Do write — extract the logic into a small pure/DI-testable function and
|
||||
call it for real:**
|
||||
|
||||
```ts
|
||||
// backend-spawn.ts
|
||||
export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
|
||||
if (!isWindows || 'windowsHide' in options) return options
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
// backend-spawn.test.ts
|
||||
test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
|
||||
assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
|
||||
assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
|
||||
assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
|
||||
})
|
||||
```
|
||||
|
||||
If the logic lives inline in a god-file (`main.ts`, `cli.py`,
|
||||
`gateway/run.py`) and extracting it feels disruptive: that's the actual
|
||||
signal to do the extraction, not to regex around it.
|
||||
|
||||
@@ -38,19 +38,22 @@ def _permission_option_supports_kind(kind: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]:
|
||||
def _build_permission_options(
|
||||
*, allow_permanent: bool, smart_denied: bool = False,
|
||||
) -> list[PermissionOption]:
|
||||
"""Return ACP options that match Hermes approval semantics."""
|
||||
options = [
|
||||
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
|
||||
PermissionOption(
|
||||
options = [PermissionOption(
|
||||
option_id="allow_once", kind="allow_once", name="Allow once",
|
||||
)]
|
||||
if not smart_denied:
|
||||
options.append(PermissionOption(
|
||||
option_id="allow_session",
|
||||
# ACP has no session-scoped kind, so use the closest persistent
|
||||
# hint while keeping Hermes semantics in the option id.
|
||||
kind="allow_always",
|
||||
name="Allow for session",
|
||||
),
|
||||
]
|
||||
if allow_permanent:
|
||||
))
|
||||
if allow_permanent and not smart_denied:
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="allow_always",
|
||||
@@ -59,7 +62,7 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption
|
||||
),
|
||||
)
|
||||
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
|
||||
if _permission_option_supports_kind("reject_always"):
|
||||
if not smart_denied and _permission_option_supports_kind("reject_always"):
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="deny_always",
|
||||
@@ -129,11 +132,15 @@ def make_approval_callback(
|
||||
description: str,
|
||||
*,
|
||||
allow_permanent: bool = True,
|
||||
smart_denied: bool = False,
|
||||
**_: object,
|
||||
) -> str:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
|
||||
options = _build_permission_options(allow_permanent=allow_permanent)
|
||||
options = _build_permission_options(
|
||||
allow_permanent=allow_permanent,
|
||||
smart_denied=smart_denied,
|
||||
)
|
||||
|
||||
tool_call = _build_permission_tool_call(command, description)
|
||||
coro = request_permission_fn(
|
||||
|
||||
+19
-3
@@ -187,10 +187,26 @@ def _normalized_custom_base_url(value: Any) -> str:
|
||||
|
||||
|
||||
def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool:
|
||||
provider_model = str(entry.get("model", "") or "").strip().lower()
|
||||
if not provider_model:
|
||||
agent_model_norm = str(agent_model or "").strip().lower()
|
||||
# Multi-model entries (v12+ `providers.<name>.models` mapping / legacy
|
||||
# `models:` list): the agent's model matching ANY catalog entry counts.
|
||||
# Without this, a provider whose `model`/`default_model` differs from the
|
||||
# session model silently fails to match and per-provider request settings
|
||||
# (extra_body, e.g. OpenAI service_tier) are dropped — billing the whole
|
||||
# session at the wrong tier (July 2026 sweeper incident: flex config
|
||||
# ignored, ~2.3x overbilling).
|
||||
models = entry.get("models")
|
||||
catalog: List[str] = []
|
||||
if isinstance(models, dict):
|
||||
catalog = [str(k).strip().lower() for k in models.keys()]
|
||||
elif isinstance(models, (list, tuple)):
|
||||
catalog = [str(m).strip().lower() for m in models]
|
||||
if catalog and agent_model_norm in catalog:
|
||||
return True
|
||||
return provider_model == str(agent_model or "").strip().lower()
|
||||
provider_model = str(entry.get("model", "") or "").strip().lower()
|
||||
if not provider_model and not catalog:
|
||||
return True
|
||||
return provider_model == agent_model_norm
|
||||
|
||||
|
||||
def _custom_provider_extra_body_for_agent(
|
||||
|
||||
@@ -474,6 +474,10 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
|
||||
"kilocode": "google/gemini-3-flash-preview",
|
||||
"ollama-cloud": "nemotron-3-nano:30b",
|
||||
"tencent-tokenhub": "hy3-preview",
|
||||
# NB: no "deepinfra" entry — its aux model lives on the ProviderProfile
|
||||
# (plugins/model-providers/deepinfra: default_aux_model), which
|
||||
# _get_aux_model_for_provider() reads first. Duplicating it here would be
|
||||
# dead data that drifts when the profile's value is bumped.
|
||||
}
|
||||
|
||||
# Legacy alias — callers that haven't been updated to _get_aux_model_for_provider()
|
||||
@@ -489,6 +493,33 @@ _PROVIDER_VISION_MODELS: Dict[str, str] = {
|
||||
"zai": "glm-5v-turbo",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_provider_vision_default(provider: str) -> Optional[str]:
|
||||
"""Return the provider's preferred default vision model id, or None.
|
||||
|
||||
Static entries in :data:`_PROVIDER_VISION_MODELS` win first (xiaomi /
|
||||
zai have dedicated vision-only model names that don't live in any
|
||||
discoverable catalog). Otherwise the provider's :class:`ProviderProfile`
|
||||
gets a chance to supply one via its ``default_vision_model()`` hook —
|
||||
that's where catalog-backed providers (DeepInfra) resolve a live default,
|
||||
keeping the discovery logic inside their plugin instead of a name-check
|
||||
branch here.
|
||||
"""
|
||||
static = _PROVIDER_VISION_MODELS.get(provider)
|
||||
if static:
|
||||
return static
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
profile = get_provider_profile(provider)
|
||||
except Exception:
|
||||
return None
|
||||
if profile is None:
|
||||
return None
|
||||
try:
|
||||
return profile.default_vision_model()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Providers whose endpoint does not accept image input, even though the
|
||||
# provider's broader ecosystem has vision models available elsewhere. When
|
||||
# `auxiliary.vision.provider: auto` sees one of these as the main provider,
|
||||
@@ -5178,6 +5209,7 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di
|
||||
_VISION_AUTO_PROVIDER_ORDER = (
|
||||
"openrouter",
|
||||
"nous",
|
||||
"deepinfra",
|
||||
)
|
||||
|
||||
|
||||
@@ -5234,6 +5266,21 @@ def _resolve_strict_vision_backend(
|
||||
return resolve_provider_client("openai-codex", model, is_vision=True)
|
||||
if provider == "anthropic":
|
||||
return _try_anthropic()
|
||||
if provider == "deepinfra":
|
||||
# DeepInfra exposes vision-capable models (Llama-4 Scout/Maverick,
|
||||
# Qwen3-VL, Gemma 3, Gemini) on the same OpenAI-compatible endpoint
|
||||
# as its chat models. The default is discovered live via the profile's
|
||||
# default_vision_model() hook (key-gated, chat-surface + vision tag) so
|
||||
# we don't pin a hardcoded id that may rot when DeepInfra retires a
|
||||
# model, and this module stays provider-agnostic.
|
||||
vision_model = model or _resolve_provider_vision_default("deepinfra")
|
||||
if not vision_model:
|
||||
logger.debug(
|
||||
"Vision auto-detect: deepinfra catalog unreachable or "
|
||||
"returned no vision-tagged models — skipping"
|
||||
)
|
||||
return None, None
|
||||
return resolve_provider_client("deepinfra", vision_model, is_vision=True)
|
||||
if provider == "custom":
|
||||
return _try_custom_endpoint()
|
||||
return None, None
|
||||
@@ -5319,16 +5366,29 @@ def resolve_vision_provider_client(
|
||||
# _PROVIDER_VISION_MODELS provides per-provider vision model
|
||||
# overrides when the provider has a dedicated multimodal model
|
||||
# that differs from the chat model (e.g. xiaomi → mimo-v2-omni,
|
||||
# zai → glm-5v-turbo). Nous is the exception: it has a dedicated
|
||||
# strict vision backend with tier-aware defaults, so it must not
|
||||
# fall through to the user's text chat model here.
|
||||
# 2. OpenRouter (vision-capable aggregator fallback)
|
||||
# zai → glm-5v-turbo). DeepInfra is similar but resolves its
|
||||
# default vision model live from the catalog (see
|
||||
# :func:`_resolve_provider_vision_default`). Nous is the
|
||||
# exception: it has a dedicated strict vision backend with
|
||||
# tier-aware defaults, so it must not fall through to the
|
||||
# user's text chat model here.
|
||||
# 2. OpenRouter (vision-capable aggregator fallback)
|
||||
# 3. Nous Portal (vision-capable aggregator fallback)
|
||||
# 4. Stop
|
||||
# 4. DeepInfra (OpenAI-compatible; vision model discovered
|
||||
# live from the catalog — tried when
|
||||
# DEEPINFRA_API_KEY is set)
|
||||
# 5. Stop
|
||||
main_provider = _read_main_provider()
|
||||
main_model = _read_main_model()
|
||||
if main_provider and main_provider not in {"auto", ""}:
|
||||
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
|
||||
# A provider-specific vision default wins over the user's chat model:
|
||||
# static overrides (xiaomi/zai) and catalog-backed discovery (the
|
||||
# DeepInfra profile hook) both yield a *known* vision-capable model,
|
||||
# whereas the pinned chat model is usually NOT multimodal (e.g. the
|
||||
# DeepSeek-V4-Flash default) and _main_model_supports_vision can't be
|
||||
# trusted to catch that. Only fall back to the chat model when no
|
||||
# provider default is available (catalog unreachable).
|
||||
vision_model = _resolve_provider_vision_default(main_provider) or main_model
|
||||
if main_provider == "nous":
|
||||
sync_client, default_model = _resolve_strict_vision_backend(
|
||||
main_provider, vision_model
|
||||
|
||||
@@ -282,7 +282,14 @@ def _record_codex_app_server_compaction(
|
||||
# The app server has already completed a real compaction boundary. Its
|
||||
# usage update (when supplied) is therefore the same real-vs-real
|
||||
# effectiveness verdict used by the normal compression path.
|
||||
if hasattr(compressor, "_verify_compaction_cleared_threshold"):
|
||||
record_boundary = getattr(
|
||||
type(compressor), "record_completed_compaction", None
|
||||
)
|
||||
if callable(record_boundary):
|
||||
# Codex owns this summary. A prior Hermes deterministic-fallback
|
||||
# flag must not leak into the native boundary's quality verdict.
|
||||
record_boundary(compressor, used_fallback=False)
|
||||
elif hasattr(compressor, "_verify_compaction_cleared_threshold"):
|
||||
compressor._verify_compaction_cleared_threshold = True
|
||||
if not getattr(turn, "token_usage_last", None):
|
||||
compressor.last_prompt_tokens = -1
|
||||
|
||||
+108
-10
@@ -736,6 +736,7 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._fallback_compression_streak = 0
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||
@@ -773,6 +774,7 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._fallback_compression_streak = 0
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
@@ -790,12 +792,84 @@ class ContextCompressor(ContextEngine):
|
||||
self._session_id = session_id or ""
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._last_summary_error = None
|
||||
self._fallback_compression_streak = 0
|
||||
self.get_active_compression_failure_cooldown()
|
||||
self._load_fallback_compression_streak()
|
||||
|
||||
def on_session_start(self, session_id: str, **kwargs) -> None:
|
||||
"""Bind session-scoped compression state for a new or resumed session."""
|
||||
super().on_session_start(session_id, **kwargs)
|
||||
self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id)
|
||||
boundary_reason = kwargs.get("boundary_reason")
|
||||
old_session_id = kwargs.get("old_session_id")
|
||||
session_db = kwargs.get("session_db", getattr(self, "_session_db", None))
|
||||
previous_fallback_streak = self._fallback_compression_streak
|
||||
if boundary_reason == "compression" and old_session_id:
|
||||
getter = getattr(session_db, "get_compression_fallback_streak", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
stored_streak = getter(old_session_id)
|
||||
if isinstance(stored_streak, (int, float, str)):
|
||||
previous_fallback_streak = max(0, int(stored_streak))
|
||||
except (TypeError, ValueError, sqlite3.Error) as exc:
|
||||
logger.debug("compression parent fallback streak lookup failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"compression parent fallback streak lookup failed (non-sqlite): %s",
|
||||
exc,
|
||||
)
|
||||
self.bind_session_state(session_db, session_id)
|
||||
if boundary_reason == "compression":
|
||||
# Rotation creates a fresh child row before this callback. Preserve
|
||||
# the logical conversation's streak until boundary bookkeeping
|
||||
# persists the updated value onto the child row.
|
||||
self._fallback_compression_streak = previous_fallback_streak
|
||||
|
||||
def _load_fallback_compression_streak(self) -> None:
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
session_id = getattr(self, "_session_id", "")
|
||||
getter = getattr(session_db, "get_compression_fallback_streak", None)
|
||||
if not session_id or not callable(getter):
|
||||
return
|
||||
try:
|
||||
stored_streak = getter(session_id)
|
||||
self._fallback_compression_streak = max(
|
||||
0,
|
||||
int(stored_streak)
|
||||
if isinstance(stored_streak, (int, float, str))
|
||||
else 0,
|
||||
)
|
||||
except (TypeError, ValueError, sqlite3.Error) as exc:
|
||||
logger.debug("compression fallback streak lookup failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.debug("compression fallback streak lookup failed (non-sqlite): %s", exc)
|
||||
|
||||
def _persist_fallback_compression_streak(self) -> None:
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
session_id = getattr(self, "_session_id", "")
|
||||
setter = getattr(session_db, "set_compression_fallback_streak", None)
|
||||
if not session_id or not callable(setter):
|
||||
return
|
||||
try:
|
||||
setter(session_id, self._fallback_compression_streak)
|
||||
except sqlite3.Error as exc:
|
||||
logger.debug("compression fallback streak persist failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.debug("compression fallback streak persist failed (non-sqlite): %s", exc)
|
||||
|
||||
def record_completed_compaction(self, *, used_fallback: bool = False) -> None:
|
||||
"""Record one completed boundary and its summary quality."""
|
||||
self._verify_compaction_cleared_threshold = True
|
||||
if used_fallback:
|
||||
self._fallback_compression_streak += 1
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Compaction completed with a deterministic fallback summary. "
|
||||
"fallback_compression_streak=%d",
|
||||
self._fallback_compression_streak,
|
||||
)
|
||||
elif self._fallback_compression_streak:
|
||||
self._fallback_compression_streak = 0
|
||||
self._persist_fallback_compression_streak()
|
||||
|
||||
def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return the live compression-failure cooldown for the bound session."""
|
||||
@@ -893,6 +967,12 @@ class ContextCompressor(ContextEngine):
|
||||
max_tokens: int | None = None,
|
||||
) -> None:
|
||||
"""Update model info after a model switch or fallback activation."""
|
||||
runtime_changed = any((
|
||||
model != self.model,
|
||||
provider != self.provider,
|
||||
base_url != self.base_url,
|
||||
api_mode != self.api_mode,
|
||||
))
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
@@ -948,6 +1028,9 @@ class ContextCompressor(ContextEngine):
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
self._ineffective_compression_count = 0
|
||||
if runtime_changed:
|
||||
self._fallback_compression_streak = 0
|
||||
self._persist_fallback_compression_streak()
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
|
||||
@@ -1137,6 +1220,10 @@ class ContextCompressor(ContextEngine):
|
||||
# Anti-thrashing: track whether last compression was effective
|
||||
self._last_compression_savings_pct: float = 100.0
|
||||
self._ineffective_compression_count: int = 0
|
||||
# Consecutive completed deterministic-fallback boundaries. Unlike the
|
||||
# real-usage effectiveness counter, ordinary fitting responses must not
|
||||
# reset this breaker; only a healthy completed summary does.
|
||||
self._fallback_compression_streak: int = 0
|
||||
# Set after a completed compression boundary; consumed by the next
|
||||
# provider-reported prompt count in update_from_response().
|
||||
self._verify_compaction_cleared_threshold: bool = False
|
||||
@@ -1190,8 +1277,10 @@ class ContextCompressor(ContextEngine):
|
||||
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
|
||||
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
|
||||
# Any real provider reading below the trigger proves the prompt
|
||||
# fits again. Clear the episode latch even when this response was
|
||||
# not the one immediately following compaction.
|
||||
# fits again. Clear the real-usage effectiveness latch even
|
||||
# when this response was not immediately after compaction. The
|
||||
# independent fallback streak is boundary-scoped and survives
|
||||
# ordinary fitting responses during context regrowth.
|
||||
self._ineffective_compression_count = 0
|
||||
else:
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
@@ -1285,6 +1374,10 @@ class ContextCompressor(ContextEngine):
|
||||
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
|
||||
if tokens < self.threshold_tokens:
|
||||
return False
|
||||
return not self._automatic_compression_blocked()
|
||||
|
||||
def _automatic_compression_blocked(self) -> bool:
|
||||
"""Return whether automatic compaction is in cooldown or tripped."""
|
||||
# Do not trigger compression while the summary LLM is in cooldown.
|
||||
# On a 429/transient failure _generate_summary() sets a cooldown and
|
||||
# returns None; compress() then inserts a static fallback marker and
|
||||
@@ -1301,18 +1394,23 @@ class ContextCompressor(ContextEngine):
|
||||
"Compression deferred — summary LLM in cooldown for %.0fs more",
|
||||
_cooldown_remaining,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
# Anti-thrashing: back off if recent compressions were ineffective
|
||||
if self._ineffective_compression_count >= 2:
|
||||
if (
|
||||
self._ineffective_compression_count >= 2
|
||||
or self._fallback_compression_streak >= 2
|
||||
):
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Compression skipped — last %d compaction attempts did not "
|
||||
"restore enough context headroom. Consider /new to start a "
|
||||
"fresh session, or /compress <topic> for focused compression.",
|
||||
"Compression skipped — repeated compaction attempts did not "
|
||||
"restore healthy context. ineffective=%d fallback=%d. "
|
||||
"Consider /new to start fresh, or /compress <topic> for "
|
||||
"focused compression.",
|
||||
self._ineffective_compression_count,
|
||||
self._fallback_compression_streak,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool output pruning (cheap pre-pass, no LLM call)
|
||||
|
||||
@@ -28,6 +28,7 @@ these paths see no behavioural change.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
@@ -52,6 +53,29 @@ COMPACTION_STATUS = (
|
||||
)
|
||||
|
||||
|
||||
def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
|
||||
"""Whether the live in-memory SessionDB class structurally predates locks.
|
||||
|
||||
In the supported hot-reload skew, this module is new while the already
|
||||
imported ``hermes_state.SessionDB`` class (and its live instances) is old.
|
||||
Only that exact class identity may fail open. Proxies, nominal lookalikes,
|
||||
non-callables, and descriptor failures must fail closed. Static lookup
|
||||
avoids invoking a present-but-broken descriptor.
|
||||
"""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
missing = object()
|
||||
return (
|
||||
type(lock_db) is SessionDB
|
||||
and inspect.getattr_static(
|
||||
SessionDB, "try_acquire_compression_lock", missing
|
||||
) is missing
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
@@ -480,6 +504,21 @@ def compress_context(
|
||||
force=force,
|
||||
)
|
||||
|
||||
# Every automatic entrypoint must honor compressor-owned cooldown and
|
||||
# breaker state. Gateway hygiene constructs a fresh AIAgent, so the
|
||||
# persisted fallback streak is loaded by bind_session_state() before this.
|
||||
if not force:
|
||||
blocked = getattr(
|
||||
type(agent.context_compressor),
|
||||
"_automatic_compression_blocked",
|
||||
None,
|
||||
)
|
||||
if callable(blocked) and blocked(agent.context_compressor):
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
# Lazy feasibility check — run the auxiliary-provider probe + context
|
||||
# length lookup just-in-time on the first compression attempt instead of
|
||||
# at AIAgent.__init__. Saves ~400ms cold off every short session that
|
||||
@@ -541,21 +580,31 @@ def compress_context(
|
||||
_lock_sid = agent.session_id or ""
|
||||
_lock_holder: Optional[str] = None
|
||||
# Probe whether the lock subsystem is actually available on this
|
||||
# SessionDB instance. A process running mismatched module versions
|
||||
# (e.g. ``conversation_compression.py`` reloaded after a pull but the
|
||||
# long-lived ``hermes_state.SessionDB`` class still bound to the
|
||||
# pre-#34351 version in memory) has the call site but not the method.
|
||||
# In that case ``try_acquire_compression_lock`` raises AttributeError —
|
||||
# NOT a ``sqlite3.Error`` — so the method's own fail-open guard never
|
||||
# runs and the exception propagates to the outer agent loop, which
|
||||
# prints the error and retries. Because compression never succeeds,
|
||||
# the token count never drops and the loop re-triggers compaction
|
||||
# forever (the "API call #47/#48/#49 ... has no attribute
|
||||
# try_acquire_compression_lock" spin). Fail OPEN here: if the lock
|
||||
# subsystem is missing or broken in any unexpected way, skip locking
|
||||
# and proceed with compression. Skipping the lock risks a rare
|
||||
# concurrent-compression session fork; an infinite no-progress loop
|
||||
# that never compresses at all is strictly worse.
|
||||
# SessionDB instance. A process running mismatched module versions can have
|
||||
# this call site while its long-lived SessionDB instance predates the lock
|
||||
# API. Only that structural absence is safe to fail open for: compression
|
||||
# must make progress rather than spin forever after an update. Once the
|
||||
# method has been resolved, every exception from its implementation fails
|
||||
# closed because proceeding without a lock can fork the session lineage.
|
||||
_try_acquire_lock = None
|
||||
_lock_lookup_error: Optional[Exception] = None
|
||||
_legacy_session_db_without_lock_api = False
|
||||
if _lock_db is not None:
|
||||
try:
|
||||
_legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db(
|
||||
_lock_db
|
||||
)
|
||||
except Exception as exc:
|
||||
_lock_lookup_error = exc
|
||||
if _lock_lookup_error is None and not _legacy_session_db_without_lock_api:
|
||||
try:
|
||||
_try_acquire_lock = _lock_db.try_acquire_compression_lock
|
||||
if not callable(_try_acquire_lock):
|
||||
_lock_lookup_error = TypeError(
|
||||
"compression lock API is present but not callable"
|
||||
)
|
||||
except Exception as exc:
|
||||
_lock_lookup_error = exc
|
||||
try:
|
||||
_lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0)
|
||||
except (TypeError, ValueError):
|
||||
@@ -564,25 +613,58 @@ def compress_context(
|
||||
_lock_refresher: Optional[_CompressionLockLeaseRefresher] = None
|
||||
if _lock_db is not None and _lock_sid:
|
||||
_lock_holder = _compression_lock_holder(agent)
|
||||
try:
|
||||
_lock_acquired = _lock_db.try_acquire_compression_lock(
|
||||
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
|
||||
if _lock_lookup_error is not None:
|
||||
# Attribute lookup itself failed for a reason other than a missing
|
||||
# lock API. It is unsafe to proceed without a lock in that case.
|
||||
_lock_holder = None
|
||||
logger.warning(
|
||||
"compression lock lookup raised unexpectedly for session=%s "
|
||||
"(%s: %s) — skipping compression this cycle",
|
||||
_lock_sid, type(_lock_lookup_error).__name__, _lock_lookup_error,
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# Broken/absent lock subsystem (version skew, etc.). Log once
|
||||
# per session and proceed WITHOUT the lock rather than letting
|
||||
# the exception spin the outer loop.
|
||||
_lock_holder = None # we don't own anything to release
|
||||
_lock_acquired = False
|
||||
elif _try_acquire_lock is None:
|
||||
# The lock API itself is absent on this in-memory instance. Log once
|
||||
# and proceed unlocked so an update-version skew cannot leave the
|
||||
# outer auto-compression loop making no progress forever.
|
||||
_lock_holder = None
|
||||
if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_error_sid = _lock_sid
|
||||
logger.warning(
|
||||
"compression lock subsystem unavailable for session=%s "
|
||||
"(%s: %s) — proceeding without lock. This usually means a "
|
||||
"stale in-memory module after an update; restart the "
|
||||
"process (or `hermes update`) to resync.",
|
||||
"— proceeding without lock. This usually means a stale "
|
||||
"in-memory module after an update; restart the process "
|
||||
"(or `hermes update`) to resync.",
|
||||
_lock_sid,
|
||||
)
|
||||
_lock_acquired = True # acquired-but-unlocked compatibility path
|
||||
else:
|
||||
try:
|
||||
_lock_acquired = _try_acquire_lock(
|
||||
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# The method exists and entered its implementation but failed.
|
||||
# Do not mistake an internal AttributeError or TypeError for
|
||||
# version skew: fail closed and preserve session lineage. A
|
||||
# failure after SQLite committed the acquire can leave our
|
||||
# holder row behind, so release it best-effort before returning
|
||||
# unchanged messages; release is holder-qualified and safe when
|
||||
# acquisition never succeeded.
|
||||
try:
|
||||
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
|
||||
except Exception as _release_err:
|
||||
logger.debug(
|
||||
"compression lock cleanup after failed acquire failed: %s",
|
||||
_release_err,
|
||||
)
|
||||
_lock_holder = None
|
||||
logger.warning(
|
||||
"compression lock acquisition raised unexpectedly for "
|
||||
"session=%s (%s: %s) — skipping compression this cycle",
|
||||
_lock_sid, type(_lock_err).__name__, _lock_err,
|
||||
)
|
||||
_lock_acquired = True # treat as acquired-but-unlocked; proceed
|
||||
_lock_acquired = False
|
||||
if not _lock_acquired:
|
||||
try:
|
||||
existing = _lock_db.get_compression_lock_holder(_lock_sid)
|
||||
@@ -651,6 +733,17 @@ def compress_context(
|
||||
_release_lock()
|
||||
raise
|
||||
|
||||
# Capture boundary quality before session-rotation callbacks run. Built-in
|
||||
# and plugin lifecycle hooks may reset per-session compressor fields while
|
||||
# rebinding to the child id; the completed attempt's verdict must survive
|
||||
# that rebind and be recorded only after the full boundary commits.
|
||||
_compression_made_progress = bool(
|
||||
getattr(agent.context_compressor, "_last_compression_made_progress", False)
|
||||
)
|
||||
_compression_used_fallback = bool(
|
||||
getattr(agent.context_compressor, "_last_summary_fallback_used", False)
|
||||
)
|
||||
|
||||
# If compression aborted (aux LLM failed to produce a usable summary)
|
||||
# the compressor returns the input messages unchanged. Surface the
|
||||
# error to the user, skip the session-rotation work entirely (no
|
||||
@@ -979,8 +1072,19 @@ def compress_context(
|
||||
# the full compaction boundary. Exceptions, aborts, and no-op attempts
|
||||
# leave this false, so unrelated later usage cannot be charged to an
|
||||
# attempt that never changed the transcript.
|
||||
if getattr(agent.context_compressor, "_last_compression_made_progress", False):
|
||||
agent.context_compressor._verify_compaction_cleared_threshold = True
|
||||
if _compression_made_progress:
|
||||
record_boundary = getattr(
|
||||
type(agent.context_compressor),
|
||||
"record_completed_compaction",
|
||||
None,
|
||||
)
|
||||
if callable(record_boundary):
|
||||
record_boundary(
|
||||
agent.context_compressor,
|
||||
used_fallback=_compression_used_fallback,
|
||||
)
|
||||
else:
|
||||
agent.context_compressor._verify_compaction_cleared_threshold = True
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
|
||||
+53
-14
@@ -128,6 +128,17 @@ _EXTRA_KEYS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
|
||||
"""Infer pool auth metadata for token formats with one unambiguous meaning."""
|
||||
if (
|
||||
provider == "anthropic"
|
||||
and isinstance(token, str)
|
||||
and token.startswith("sk-ant-oat")
|
||||
):
|
||||
return AUTH_TYPE_OAUTH
|
||||
return str(auth_type or AUTH_TYPE_API_KEY)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PooledCredential:
|
||||
provider: str
|
||||
@@ -157,6 +168,11 @@ class PooledCredential:
|
||||
def __post_init__(self):
|
||||
if self.extra is None:
|
||||
self.extra = {}
|
||||
self.auth_type = _normalize_pool_auth_type(
|
||||
self.provider,
|
||||
self.access_token,
|
||||
self.auth_type,
|
||||
)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name in _EXTRA_KEYS:
|
||||
@@ -1416,6 +1432,11 @@ class CredentialPool:
|
||||
entries_to_prune: List[str] = []
|
||||
available: List[PooledCredential] = []
|
||||
for entry in self._entries:
|
||||
# Borrowed credentials persist as metadata-only references and are
|
||||
# hydrated from their live source on load. A stale duplicate row
|
||||
# can remain unhydrated; never lease or select it as an empty key.
|
||||
if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key:
|
||||
continue
|
||||
# For anthropic claude_code entries, sync from the credentials file
|
||||
# before any status/refresh checks. This picks up tokens refreshed
|
||||
# by other processes (Claude Code CLI, other Hermes profiles).
|
||||
@@ -1732,11 +1753,15 @@ class CredentialPool:
|
||||
|
||||
|
||||
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
|
||||
existing_idx = None
|
||||
matching_indices = []
|
||||
for idx, entry in enumerate(entries):
|
||||
if entry.source == source:
|
||||
existing_idx = idx
|
||||
break
|
||||
matching_indices.append(idx)
|
||||
|
||||
existing_idx = matching_indices[0] if matching_indices else None
|
||||
duplicate_indices = set(matching_indices[1:])
|
||||
if duplicate_indices:
|
||||
entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices]
|
||||
|
||||
if existing_idx is None:
|
||||
payload.setdefault("id", uuid.uuid4().hex[:6])
|
||||
@@ -1768,8 +1793,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
||||
# Runtime-only borrowed secret updates should refresh the in-memory
|
||||
# entry without forcing auth.json churn when the disk-safe payload is
|
||||
# unchanged (for example env keys with the same fingerprint).
|
||||
return existing.to_dict() != updated.to_dict()
|
||||
return False
|
||||
return bool(duplicate_indices) or existing.to_dict() != updated.to_dict()
|
||||
return bool(duplicate_indices)
|
||||
|
||||
|
||||
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
|
||||
@@ -2243,12 +2268,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
if _is_source_suppressed(provider, source):
|
||||
continue
|
||||
active_sources.add(source)
|
||||
# Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path.
|
||||
auth_type = (
|
||||
AUTH_TYPE_OAUTH
|
||||
if provider == "anthropic" and token.startswith("sk-ant-oat")
|
||||
else AUTH_TYPE_API_KEY
|
||||
)
|
||||
base_url = env_url or pconfig.inference_base_url
|
||||
if provider == "kimi-coding":
|
||||
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
|
||||
@@ -2263,7 +2282,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
env_var=env_var,
|
||||
token=token,
|
||||
base_url=base_url,
|
||||
auth_type=auth_type,
|
||||
),
|
||||
)
|
||||
return changed, active_sources
|
||||
@@ -2391,16 +2409,37 @@ def load_pool(provider: str) -> CredentialPool:
|
||||
for payload in raw_entries
|
||||
)
|
||||
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
|
||||
raw_needs_auth_normalization = any(
|
||||
isinstance(payload, dict)
|
||||
and _normalize_pool_auth_type(
|
||||
provider,
|
||||
payload.get("access_token"),
|
||||
payload.get("auth_type", AUTH_TYPE_API_KEY),
|
||||
) != payload.get("auth_type", AUTH_TYPE_API_KEY)
|
||||
for payload in raw_entries
|
||||
)
|
||||
if raw_needs_auth_normalization:
|
||||
# A profile may be reading this provider from the global-root fallback.
|
||||
# Keep that fallback read-only: only the store that owns these rows may
|
||||
# rewrite them. Loading the default/root profile will heal global rows.
|
||||
active_pool = _load_auth_store().get("credential_pool")
|
||||
active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None
|
||||
raw_needs_auth_normalization = bool(active_entries)
|
||||
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
# Custom endpoint pool — seed from custom_providers config and model config
|
||||
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
|
||||
changed = raw_needs_sanitization or custom_changed
|
||||
changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed
|
||||
changed |= _prune_stale_seeded_entries(entries, custom_sources)
|
||||
else:
|
||||
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
|
||||
env_changed, env_sources = _seed_from_env(provider, entries)
|
||||
changed = raw_needs_sanitization or singleton_changed or env_changed
|
||||
changed = (
|
||||
raw_needs_sanitization
|
||||
or raw_needs_auth_normalization
|
||||
or singleton_changed
|
||||
or env_changed
|
||||
)
|
||||
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
|
||||
# process missing a provider env var must not delete the persisted
|
||||
# pool entry for every other process (#9331). File-backed singletons
|
||||
|
||||
@@ -807,6 +807,24 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pricing["completion"] = str(float(novita_output) / 10_000 / 1_000_000)
|
||||
return pricing
|
||||
|
||||
# DeepInfra ships pricing under ``metadata.pricing`` with $/MTok values:
|
||||
# ``input_tokens``, ``output_tokens``, ``cache_read_tokens``. Convert to
|
||||
# per-token strings so the generic cost machinery (usage_pricing.py)
|
||||
# consumes them through the same path as OpenRouter / OpenAI.
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None
|
||||
deepinfra_pricing = metadata.get("pricing") if metadata else None
|
||||
if isinstance(deepinfra_pricing, dict) and any(
|
||||
k in deepinfra_pricing for k in ("input_tokens", "output_tokens", "cache_read_tokens")
|
||||
):
|
||||
result: Dict[str, Any] = {}
|
||||
if deepinfra_pricing.get("input_tokens") is not None:
|
||||
result["prompt"] = str(float(deepinfra_pricing["input_tokens"]) / 1_000_000)
|
||||
if deepinfra_pricing.get("output_tokens") is not None:
|
||||
result["completion"] = str(float(deepinfra_pricing["output_tokens"]) / 1_000_000)
|
||||
if deepinfra_pricing.get("cache_read_tokens") is not None:
|
||||
result["cache_read"] = str(float(deepinfra_pricing["cache_read_tokens"]) / 1_000_000)
|
||||
return result
|
||||
|
||||
alias_map = {
|
||||
"prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"),
|
||||
"completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"),
|
||||
|
||||
@@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({
|
||||
"openai",
|
||||
"mistral",
|
||||
"xai",
|
||||
"elevenlabs",
|
||||
"deepinfra",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ _BUILTIN_NAMES = frozenset({
|
||||
"neutts",
|
||||
"kittentts",
|
||||
"piper",
|
||||
"deepinfra",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -449,6 +449,11 @@ def finalize_turn(
|
||||
"estimated_cost_usd": agent.session_estimated_cost_usd,
|
||||
"cost_status": agent.session_cost_status,
|
||||
"cost_source": agent.session_cost_source,
|
||||
# Requested service tier (from request_overrides.extra_body), for
|
||||
# billing audits by callers like `hermes -z --usage-file`.
|
||||
"service_tier": (
|
||||
(getattr(agent, "request_overrides", {}) or {}).get("extra_body") or {}
|
||||
).get("service_tier"),
|
||||
"session_id": agent.session_id,
|
||||
}
|
||||
if agent._tool_guardrail_halt_decision is not None:
|
||||
|
||||
@@ -244,6 +244,78 @@ def save_bytes_video(
|
||||
return path
|
||||
|
||||
|
||||
_URL_VIDEO_CONTENT_TYPES = {
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
"video/quicktime": "mov",
|
||||
"video/x-matroska": "mkv",
|
||||
}
|
||||
|
||||
|
||||
def save_url_video(
|
||||
url: str,
|
||||
*,
|
||||
prefix: str = "video",
|
||||
timeout: float = 180.0,
|
||||
max_bytes: int = 200 * 1024 * 1024,
|
||||
) -> Path:
|
||||
"""Download a video URL and write it under ``$HERMES_HOME/cache/videos/``.
|
||||
|
||||
The video twin of :func:`agent.image_gen_provider.save_url_image`: several
|
||||
backends (DeepInfra, FAL) return an *ephemeral* delivery URL that expires
|
||||
before a downstream consumer can fetch it, so we materialise the bytes
|
||||
locally at tool-completion time. Streams with a size cap.
|
||||
|
||||
Raises on any network / HTTP / oversize error so callers can fall back to
|
||||
returning the bare URL.
|
||||
"""
|
||||
import requests
|
||||
|
||||
response = requests.get(url, timeout=timeout, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
extension = _URL_VIDEO_CONTENT_TYPES.get(content_type)
|
||||
if extension is None:
|
||||
url_path = url.split("?", 1)[0].lower()
|
||||
for ext in ("mp4", "webm", "mov", "mkv"):
|
||||
if url_path.endswith(f".{ext}"):
|
||||
extension = ext
|
||||
break
|
||||
if extension is None:
|
||||
extension = "mp4"
|
||||
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
short = uuid.uuid4().hex[:8]
|
||||
path = _videos_cache_dir() / f"{prefix}_{ts}_{short}.{extension}"
|
||||
|
||||
bytes_written = 0
|
||||
with path.open("wb") as fh:
|
||||
for chunk in response.iter_content(chunk_size=256 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
bytes_written += len(chunk)
|
||||
if bytes_written > max_bytes:
|
||||
fh.close()
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise ValueError(
|
||||
f"Video at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache."
|
||||
)
|
||||
fh.write(chunk)
|
||||
|
||||
if bytes_written == 0:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise ValueError(f"Video at {url} was empty (0 bytes).")
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def success_response(
|
||||
*,
|
||||
video: str,
|
||||
@@ -297,3 +369,222 @@ def error_response(
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reusable OpenAI-compatible backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
"""Generic text/image-to-video over the OpenAI ``client.videos`` API.
|
||||
|
||||
DeepInfra, OpenAI/Sora, and OpenRouter all expose the same
|
||||
``POST /videos`` async-job shape (``create`` → poll → ``download_content``),
|
||||
so the SDK call lives here once. A concrete backend only needs to declare
|
||||
its identity and credentials::
|
||||
|
||||
class FooVideoGenProvider(OpenAICompatibleVideoGenProvider):
|
||||
name = "foo"
|
||||
_env_key = "FOO_API_KEY"
|
||||
_default_base_url = "https://api.foo.com/v1/openai"
|
||||
def list_models(self):
|
||||
return [...] # entries with an "id" key; default_model() uses [0]
|
||||
|
||||
``image_url`` routes to image-to-video; its absence routes to text-to-video.
|
||||
Provider-specific fields (``image_url``/``negative_prompt``/``seed``) ride
|
||||
in ``extra_body`` so they pass through the SDK unchanged.
|
||||
"""
|
||||
|
||||
_env_key: str = "OPENAI_API_KEY"
|
||||
_default_base_url: str = "https://api.openai.com/v1"
|
||||
|
||||
# Polling cadence for the async video job. The OpenAI SDK's
|
||||
# ``create_and_poll`` defaults to ~1 poll/second and loops forever on a
|
||||
# non-terminal status, so a multi-minute job issues hundreds of sequential
|
||||
# requests and a stuck job pins its tool-executor worker thread with no way
|
||||
# out. We hand-roll a bounded poll instead: a coarse interval plus a hard
|
||||
# wall-clock deadline that surfaces a timeout error.
|
||||
_poll_interval_s: float = 5.0
|
||||
_poll_deadline_s: float = 900.0
|
||||
|
||||
def _api_key(self) -> str:
|
||||
import os
|
||||
|
||||
return os.environ.get(self._env_key, "").strip()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self._api_key())
|
||||
|
||||
def _create_and_poll(self, client: Any, call_kwargs: Dict[str, Any]) -> Any:
|
||||
"""Create the video job and poll to completion with a hard deadline.
|
||||
|
||||
Replaces ``client.videos.create_and_poll`` (unbounded 1/s loop) with a
|
||||
coarse interval and a wall-clock cap. Returns the terminal video object
|
||||
(any status); raises :class:`TimeoutError` if the deadline passes
|
||||
first.
|
||||
"""
|
||||
import time
|
||||
|
||||
video = client.videos.create(**call_kwargs)
|
||||
terminal = {"completed", "succeeded", "failed", "error", "cancelled", "canceled"}
|
||||
deadline = time.monotonic() + self._poll_deadline_s
|
||||
while getattr(video, "status", None) not in terminal:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"video job {getattr(video, 'id', '?')} did not reach a terminal "
|
||||
f"status within {int(self._poll_deadline_s)}s "
|
||||
f"(last status={getattr(video, 'status', None)!r})"
|
||||
)
|
||||
time.sleep(self._poll_interval_s)
|
||||
video = client.videos.retrieve(video.id)
|
||||
return video
|
||||
|
||||
def _base_url(self) -> str:
|
||||
import os
|
||||
|
||||
override = os.environ.get(f"{self.name.upper()}_BASE_URL", "").strip()
|
||||
return override or self._default_base_url
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
duration: Optional[int] = None,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
resolution: str = DEFAULT_RESOLUTION,
|
||||
negative_prompt: Optional[str] = None,
|
||||
audio: Optional[bool] = None,
|
||||
seed: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
if not prompt or not prompt.strip():
|
||||
return error_response(
|
||||
error="prompt is required", error_type="invalid_request", provider=self.name
|
||||
)
|
||||
if not self._api_key():
|
||||
return error_response(
|
||||
error=f"{self._env_key} is not set",
|
||||
error_type="missing_credentials",
|
||||
provider=self.name,
|
||||
)
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="openai Python package not installed (pip install openai)",
|
||||
error_type="missing_dependency",
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
model_id = model or self.default_model()
|
||||
if not model_id:
|
||||
return error_response(
|
||||
error=f"no {self.name} video model available (live catalog empty?)",
|
||||
error_type="no_model",
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
# Provider-specific fields the OpenAI ``videos.create`` signature does
|
||||
# not name natively — pass them through ``extra_body``.
|
||||
extra_body = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"negative_prompt": negative_prompt,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"image_url": image_url, # presence ⇒ image-to-video
|
||||
"seed": seed,
|
||||
}.items()
|
||||
if v is not None
|
||||
}
|
||||
call_kwargs: Dict[str, Any] = {"model": model_id, "prompt": prompt}
|
||||
if duration:
|
||||
call_kwargs["seconds"] = str(duration)
|
||||
if resolution:
|
||||
call_kwargs["size"] = resolution
|
||||
if extra_body:
|
||||
call_kwargs["extra_body"] = extra_body
|
||||
|
||||
client = openai.OpenAI(api_key=self._api_key(), base_url=self._base_url())
|
||||
try:
|
||||
try:
|
||||
video = self._create_and_poll(client, call_kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - surface any SDK/API/timeout failure uniformly
|
||||
logger.debug("%s video generation failed", self.name, exc_info=True)
|
||||
return error_response(
|
||||
error=f"{self.name} video generation failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider=self.name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
# Terminal success status differs across backends: DeepInfra reports
|
||||
# "succeeded", OpenAI/Sora reports "completed". Accept both.
|
||||
status = getattr(video, "status", None)
|
||||
if status not in ("completed", "succeeded"):
|
||||
# ``video.error`` is a structured SDK object (pydantic
|
||||
# VideoCreateError), not a string — str() it so the response
|
||||
# dict stays JSON-serializable for the tool layer.
|
||||
job_error = getattr(video, "error", None)
|
||||
return error_response(
|
||||
error=str(job_error) if job_error else f"video job ended with status={status!r}",
|
||||
error_type="job_failed",
|
||||
provider=self.name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
# Resolve the output. Providers expose it either as a delivery URL in
|
||||
# the job's ``data`` list (DeepInfra, FAL-style) or only via the SDK
|
||||
# download endpoint (OpenAI/Sora). Download the bytes and save locally
|
||||
# so the caller gets a durable file — DeepInfra's delivery URLs in
|
||||
# particular are short-lived. Matches plugins/image_gen/deepinfra.
|
||||
url = None
|
||||
for item in getattr(video, "data", None) or []:
|
||||
candidate = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
|
||||
if candidate:
|
||||
url = candidate
|
||||
break
|
||||
|
||||
try:
|
||||
if url:
|
||||
# Materialise the (often short-lived) delivery URL locally.
|
||||
video_ref = str(save_url_video(url, prefix=self.name))
|
||||
else:
|
||||
# OpenAI/Sora style: no public URL — pull bytes via the SDK.
|
||||
raw = client.videos.download_content(video.id).read()
|
||||
video_ref = str(save_bytes_video(raw, prefix=self.name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if url:
|
||||
# Best-effort: hand back the URL rather than fail outright.
|
||||
logger.debug("%s: saving video locally failed (%s); returning URL", self.name, exc)
|
||||
video_ref = url
|
||||
else:
|
||||
return error_response(
|
||||
error=f"{self.name} video job succeeded but no output could be retrieved: {exc}",
|
||||
error_type="empty_response",
|
||||
provider=self.name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
return success_response(
|
||||
video=video_ref,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
modality="image" if image_url else "text",
|
||||
aspect_ratio=aspect_ratio,
|
||||
duration=duration or 0,
|
||||
provider=self.name,
|
||||
)
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
@@ -11,12 +11,15 @@ Active selection
|
||||
The active provider is chosen by ``video_gen.provider`` in ``config.yaml``.
|
||||
If unset, :func:`get_active_provider` applies fallback logic:
|
||||
|
||||
1. If exactly one provider is registered, use it.
|
||||
1. If exactly one *available* provider is registered, use it.
|
||||
2. Otherwise return ``None`` (the tool surfaces a helpful error pointing
|
||||
the user at ``hermes tools``).
|
||||
|
||||
Mirrors ``agent/image_gen_registry.py`` so the two surfaces behave the
|
||||
same.
|
||||
same: the unconfigured fallback is filtered by ``is_available()`` so a box
|
||||
that has credentials for only one backend (e.g. DeepInfra, while the
|
||||
``fal``/``xai`` plugins also register unconditionally) auto-selects it
|
||||
instead of returning ``None``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -100,13 +103,26 @@ def get_active_provider() -> Optional[VideoGenProvider]:
|
||||
if provider is not None:
|
||||
return provider
|
||||
logger.debug(
|
||||
"video_gen.provider='%s' configured but not registered; falling back",
|
||||
"video_gen.provider='%s' configured but not registered; failing closed",
|
||||
configured,
|
||||
)
|
||||
return None
|
||||
|
||||
# Fallback: single-provider case
|
||||
if len(snapshot) == 1:
|
||||
return next(iter(snapshot.values()))
|
||||
def _is_available_safe(p: VideoGenProvider) -> bool:
|
||||
"""Wrap ``is_available()`` so a buggy provider doesn't kill resolution."""
|
||||
try:
|
||||
return bool(p.is_available())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("video_gen provider %s.is_available() raised %s", p.name, exc)
|
||||
return False
|
||||
|
||||
# Fallback: single *available* provider — filter by is_available() so a
|
||||
# box with credentials for only one backend auto-selects it even when
|
||||
# other providers (fal/xai) register unconditionally without keys.
|
||||
# Mirrors agent/image_gen_registry.get_active_provider().
|
||||
available = [p for p in snapshot.values() if _is_available_safe(p)]
|
||||
if len(available) == 1:
|
||||
return available[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"typecheck": "tsc -p . --noEmit"
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"check": "npm run typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* backend-child.ts
|
||||
*
|
||||
* Windows-aware teardown for the desktop's managed backend child process.
|
||||
*
|
||||
* Node's `child.kill()` only signals the direct child. On Windows a backend
|
||||
* that spawned its own grandchildren (a `hermes` REPL, a pty terminal
|
||||
* session, the gateway) survives a plain SIGTERM and keeps files (e.g. the
|
||||
* venv shim) locked. So on Windows we tree-kill via `forceKillProcessTree`;
|
||||
* everywhere else a plain SIGTERM is correct and sufficient (POSIX has no
|
||||
* mandatory locks, and the backend is not spawned detached so there's no
|
||||
* process-group to negative-pid-kill).
|
||||
*
|
||||
* Extracted into its own dependency-free module (no electron import) so the
|
||||
* SIGTERM-vs-tree-kill branching can be asserted directly with a fake child
|
||||
* object and a spy `forceKillProcessTree`, instead of grepping main.ts source
|
||||
* text for the function body.
|
||||
*/
|
||||
|
||||
export interface StopBackendChildDeps {
|
||||
/** Defaults to the real platform check; injectable for tests. */
|
||||
isWindows?: boolean
|
||||
/** Windows tree-kill implementation (real: taskkill /T /F via execFileSync). */
|
||||
forceKillProcessTree: (pid: number) => void
|
||||
}
|
||||
|
||||
export interface KillableChild {
|
||||
pid?: number | null
|
||||
killed?: boolean
|
||||
kill: (signal: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a managed child process, choosing the right strategy for the platform.
|
||||
* No-ops silently if `child` is falsy, already killed, or the kill attempt
|
||||
* throws (the process may already be gone) -- mirrors the original inline
|
||||
* best-effort semantics in main.ts.
|
||||
*/
|
||||
export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) {
|
||||
if (!child || child.killed) {
|
||||
return
|
||||
}
|
||||
|
||||
const isWindows = deps.isWindows ?? process.platform === 'win32'
|
||||
|
||||
try {
|
||||
if (isWindows && Number.isInteger(child.pid)) {
|
||||
deps.forceKillProcessTree(child.pid as number)
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict'
|
||||
|
||||
// Backend subcommand routing for the desktop-managed Hermes process.
|
||||
//
|
||||
// The desktop app launches its own headless backend via `hermes serve` — it
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
appendUniquePathEntries,
|
||||
|
||||
@@ -44,12 +44,14 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
@@ -77,6 +79,7 @@ function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatfor
|
||||
if (!hermesHome) {
|
||||
return hermesHome
|
||||
}
|
||||
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
|
||||
@@ -60,6 +60,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
child.stdout.off('data', onData)
|
||||
@@ -130,12 +131,14 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
@@ -171,6 +174,7 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref()
|
||||
}
|
||||
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
bundledRuntimeImportCheck,
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPinArgs,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the renderer.
|
||||
*
|
||||
* Wired from electron/main.ts:
|
||||
* import { runBootstrap }from './bootstrap-runner.ts'
|
||||
* import { runBootstrap }from './bootstrap-runner'
|
||||
* const result = await runBootstrap({
|
||||
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
|
||||
* activeRoot, // ACTIVE_HERMES_ROOT
|
||||
@@ -38,16 +38,10 @@ import fsp from 'node:fs/promises'
|
||||
import https from 'node:https'
|
||||
import path from 'node:path'
|
||||
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
@@ -73,6 +67,7 @@ function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
@@ -96,6 +91,7 @@ function installedAgentInstallScript(hermesHome) {
|
||||
if (!hermesHome) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
@@ -425,6 +421,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
|
||||
@@ -441,6 +438,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, signal, killed } as any)
|
||||
})
|
||||
})
|
||||
@@ -517,6 +515,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
|
||||
@@ -532,6 +531,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
AT_COOKIE_VARIANTS,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
adoptServedDashboardToken,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
|
||||
@@ -106,6 +106,7 @@ function resolveRemovableAppPath(execPath, platform, env: any = {}) {
|
||||
if (env.APPIMAGE) {
|
||||
return env.APPIMAGE
|
||||
}
|
||||
|
||||
// Unpacked electron-builder tree: …/linux-unpacked/hermes
|
||||
const dir = p.dirname(exe)
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
|
||||
function mkTmpDir() {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function probeGatewayWebSocket<T>(
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimers()
|
||||
|
||||
@@ -107,6 +108,7 @@ function probeGatewayWebSocket<T>(
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
opened = true
|
||||
// Upgrade accepted. Give the server a brief window to reject the
|
||||
// credential post-handshake (early close) before declaring success.
|
||||
@@ -189,6 +191,7 @@ function extractErrorReason(event) {
|
||||
if (event instanceof Error) {
|
||||
return event.message
|
||||
}
|
||||
|
||||
const err = event.error || event.message
|
||||
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveRenamePath } from './git-review-ops'
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { gitRootForIpc } from './git-root'
|
||||
|
||||
function mkTmpDir() {
|
||||
@@ -19,20 +20,23 @@ test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => {
|
||||
const root = mkTmpDir()
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
try {
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,11 +3,13 @@ import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
parseWorktrees,
|
||||
sanitizeBranch,
|
||||
@@ -210,3 +212,53 @@ test('addWorktree: existing default branch switches the main checkout, not .work
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBaseBranches: lists local branches and flags the default', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-branches-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
const trunk = git('branch', '--show-current')
|
||||
execFileSync('git', ['branch', 'feature'], { cwd: dir })
|
||||
|
||||
const branches = await listBaseBranches(dir, 'git')
|
||||
const names = branches.map(b => b.name).sort()
|
||||
|
||||
assert.deepEqual(names, [trunk, 'feature'].sort())
|
||||
// No remote → all local.
|
||||
assert.equal(branches.every(b => !b.isRemote), true)
|
||||
// The trunk is flagged as the default.
|
||||
assert.equal(branches.find(b => b.name === trunk).isDefault, true)
|
||||
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBaseBranches: empty on a non-repo path', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-nonrepo-'))
|
||||
|
||||
try {
|
||||
assert.deepEqual(await listBaseBranches(dir, 'git'), [])
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('addWorktree: base param branches off a specified local branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-add-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'staging'], { cwd: dir })
|
||||
|
||||
const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git')
|
||||
|
||||
assert.equal(result.branch, 'new-from-staging')
|
||||
assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -251,7 +251,25 @@ async function addWorktree(repoPath, options, gitBin) {
|
||||
const args = ['worktree', 'add', '-b', branch, dir]
|
||||
|
||||
if (opts.base) {
|
||||
args.push(String(opts.base))
|
||||
// Remote-tracking branches may be stale or missing if the user hasn't
|
||||
// fetched recently. When the base is an `origin/…` ref, fetch just that
|
||||
// branch so `git worktree add -b new origin/main` works against the
|
||||
// latest remote commit. Local branches are used as-is.
|
||||
const base = String(opts.base)
|
||||
|
||||
if (base.startsWith('origin/')) {
|
||||
const remoteBranch = base.slice('origin/'.length)
|
||||
|
||||
try {
|
||||
await runGit(gitBin, ['fetch', 'origin', remoteBranch], root)
|
||||
} catch {
|
||||
// The fetch isn't mandatory, but it would be nice to do if possible.
|
||||
// If it's not possible, just use the local ref of the remote branch.
|
||||
// If it doesn't exist locally, we'll get an error anyways
|
||||
}
|
||||
}
|
||||
|
||||
args.push(base)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -337,9 +355,56 @@ async function switchBranch(repoPath, branch, gitBin) {
|
||||
return { branch: target }
|
||||
}
|
||||
|
||||
// Branches the new worktree can be based on: local heads + remote-tracking
|
||||
// refs. Listed most-recently-committed first; the remote's default branch
|
||||
// (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo /
|
||||
// remote backend where the probe can't run.
|
||||
async function listBaseBranches(repoPath, gitBin) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runGit(
|
||||
gitBin,
|
||||
['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'],
|
||||
resolved
|
||||
)
|
||||
|
||||
const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved)
|
||||
const localDefault = await defaultBranch(gitBin, resolved)
|
||||
|
||||
return out
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [name] = line.split('\t')
|
||||
|
||||
return {
|
||||
name,
|
||||
isRemote: name.startsWith('origin/'),
|
||||
// origin/HEAD when a remote exists; otherwise the local default
|
||||
// (main/master/init.defaultBranch) so a no-remote repo still flags
|
||||
// its trunk.
|
||||
isDefault: Boolean(
|
||||
(remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault)
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
parseWorktrees,
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
@@ -122,171 +123,184 @@ test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const textPath = path.join(tempDir, 'notes.txt')
|
||||
fs.writeFileSync(textPath, 'hello world', 'utf8')
|
||||
try {
|
||||
const textPath = path.join(tempDir, 'notes.txt')
|
||||
fs.writeFileSync(textPath, 'hello world', 'utf8')
|
||||
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
|
||||
baseDir: tempDir,
|
||||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
|
||||
baseDir: tempDir,
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/file does not exist/
|
||||
)
|
||||
|
||||
const nestedDir = path.join(tempDir, 'directory')
|
||||
fs.mkdirSync(nestedDir)
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(nestedDir, {
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/path points to a directory/
|
||||
)
|
||||
|
||||
const largePath = path.join(tempDir, 'large.txt')
|
||||
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(largePath, {
|
||||
maxBytes: 8,
|
||||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/file is too large/
|
||||
)
|
||||
})
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(envPath, {
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/blocked for sensitive file/
|
||||
)
|
||||
})
|
||||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/file does not exist/
|
||||
)
|
||||
|
||||
const nestedDir = path.join(tempDir, 'directory')
|
||||
fs.mkdirSync(nestedDir)
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(nestedDir, {
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/path points to a directory/
|
||||
)
|
||||
|
||||
const largePath = path.join(tempDir, 'large.txt')
|
||||
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(largePath, {
|
||||
maxBytes: 8,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/file is too large/
|
||||
)
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(envPath, {
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/blocked for sensitive file/
|
||||
)
|
||||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
try {
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
return
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
// symlink creation is not permitted on this platform — skip
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
return
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
|
||||
try {
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
// directory symlink creation is not permitted on this platform — skip
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
})
|
||||
|
||||
@@ -112,6 +112,7 @@ function sensitiveFileBlockReason(filePath) {
|
||||
|
||||
function ipcPathError(code: any, message: string): Error & { code: any } {
|
||||
const error = new Error(message) as Error & { code: any }
|
||||
|
||||
;(error as any).code = code
|
||||
|
||||
return error
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
createLinkTitleWindow,
|
||||
|
||||
@@ -55,6 +55,7 @@ export function readLinkTitleWindowTitle(window) {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const contents = window.webContents
|
||||
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
|
||||
+131
-184
@@ -1,5 +1,4 @@
|
||||
type Method = string
|
||||
import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
|
||||
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from 'electron'
|
||||
import nodePty from 'node-pty'
|
||||
|
||||
import { stopBackendChild as stopBackendChildImpl } from './backend-child'
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { canImportHermesCli, verifyHermesCli } from './backend-probes'
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
reviewUnstage
|
||||
} from './git-review-ops'
|
||||
import { gitRootForIpc } from './git-root'
|
||||
import { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
|
||||
import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
|
||||
import {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
} from './hardening'
|
||||
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
import {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
@@ -125,6 +126,8 @@ import {
|
||||
MIN_HEIGHT as WINDOW_MIN_HEIGHT,
|
||||
MIN_WIDTH as WINDOW_MIN_WIDTH
|
||||
} from './window-state'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
import { readWindowsUserEnvVar } from './windows-user-env'
|
||||
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
|
||||
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
|
||||
@@ -152,14 +155,6 @@ const APP_ROOT = app.getAppPath()
|
||||
// Dev (`npm run dev`) and prod both load the esbuild output from dist/.
|
||||
const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js')
|
||||
|
||||
function hiddenWindowsChildOptions(options: any = {}): ExecFileSyncOptionsWithStringEncoding {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options as any
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true } as any
|
||||
}
|
||||
|
||||
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||||
// wire, so the window flashes during scroll/streaming/animation. Local
|
||||
@@ -870,6 +865,7 @@ function planDesktopLogRotation(size) {
|
||||
if (size < DESKTOP_LOG_MAX_BYTES) {
|
||||
return []
|
||||
}
|
||||
|
||||
const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1))
|
||||
|
||||
// Pathological boot-loop log: reclaim live + every backup outright.
|
||||
@@ -937,6 +933,7 @@ function flushDesktopLogBufferSync() {
|
||||
if (!desktopLogBuffer) {
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = desktopLogBuffer
|
||||
desktopLogBuffer = ''
|
||||
|
||||
@@ -953,6 +950,7 @@ function flushDesktopLogBufferAsync() {
|
||||
if (!desktopLogBuffer) {
|
||||
return desktopLogFlushPromise
|
||||
}
|
||||
|
||||
const chunk = desktopLogBuffer
|
||||
desktopLogBuffer = ''
|
||||
|
||||
@@ -973,6 +971,7 @@ function scheduleDesktopLogFlush() {
|
||||
if (desktopLogFlushTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
desktopLogFlushTimer = setTimeout(() => {
|
||||
desktopLogFlushTimer = null
|
||||
void flushDesktopLogBufferAsync()
|
||||
@@ -985,6 +984,7 @@ function rememberLog(chunk) {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`)
|
||||
hermesLog.push(...lines)
|
||||
|
||||
@@ -1183,11 +1183,13 @@ function broadcastBootProgress() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:boot-progress', bootProgressState)
|
||||
}
|
||||
|
||||
@@ -1267,11 +1269,13 @@ function broadcastBootstrapEvent(ev) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:bootstrap:event', ev)
|
||||
}
|
||||
|
||||
@@ -1421,9 +1425,7 @@ function findOnPath(command) {
|
||||
// shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`.
|
||||
// The empty entry is kept LAST so callers that already include the extension
|
||||
// (py.exe, pwsh.exe, powershell.exe) still resolve.
|
||||
const extensions = IS_WINDOWS
|
||||
? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
||||
: ['']
|
||||
const extensions = buildPathExtCandidates(process.env.PATHEXT, IS_WINDOWS)
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
for (const extension of extensions) {
|
||||
@@ -1443,71 +1445,21 @@ function isCommandScript(command) {
|
||||
}
|
||||
|
||||
function unwrapWindowsVenvHermesCommand(command, backendArgs) {
|
||||
if (!IS_WINDOWS || !command || isCommandScript(command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = path.resolve(String(command))
|
||||
|
||||
if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptsDir = path.dirname(resolved)
|
||||
|
||||
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') {
|
||||
return null
|
||||
}
|
||||
|
||||
const venvRoot = path.dirname(scriptsDir)
|
||||
const python = getVenvPython(venvRoot)
|
||||
|
||||
if (!fileExists(python)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = path.dirname(venvRoot)
|
||||
|
||||
// Smoke-test the venv interpreter before trusting it. A venv whose update
|
||||
// died mid-`pip install` still has python.exe + hermes.exe on disk, but the
|
||||
// backend dies on its first import (e.g. ModuleNotFoundError: dotenv) before
|
||||
// the gateway ever binds. Returning it here also BYPASSED the caller's
|
||||
// `--version` probe, so Retry/"Repair install" re-resolved the same broken
|
||||
// venv forever instead of falling through to the bootstrap installer.
|
||||
// Mirror isActiveRuntimeUsable(): probe with the checkout on PYTHONPATH so a
|
||||
// healthy source-tree venv passes.
|
||||
if (
|
||||
!canImportHermesCli(python, {
|
||||
env: {
|
||||
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter)
|
||||
}
|
||||
})
|
||||
) {
|
||||
rememberLog(
|
||||
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: `existing Hermes Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
// Surfaced so backendSupportsServe() can read this runtime's source for the
|
||||
// `serve` capability check instead of falling back to a heavyweight probe.
|
||||
root,
|
||||
shell: false
|
||||
}
|
||||
return resolveVenvHermesCommand(command, backendArgs, {
|
||||
isWindows: IS_WINDOWS,
|
||||
isCommandScript,
|
||||
fileExists,
|
||||
directoryExists,
|
||||
canImportHermesCli,
|
||||
getVenvPython,
|
||||
getVenvSitePackagesEntries,
|
||||
buildDesktopBackendEnv,
|
||||
hermesHome: HERMES_HOME,
|
||||
resolvePath: (...segments) => path.resolve(...segments),
|
||||
dirname: p => path.dirname(p),
|
||||
basename: p => path.basename(p),
|
||||
rememberLog
|
||||
})
|
||||
}
|
||||
|
||||
// Does the resolved runtime understand the `serve` subcommand? The desktop
|
||||
@@ -1526,6 +1478,7 @@ function backendSupportsServe(backend) {
|
||||
if (!backend || !backend.command) {
|
||||
return true
|
||||
}
|
||||
|
||||
const key = `${backend.command}::${backend.root || ''}`
|
||||
|
||||
if (_serveSupportCache.has(key)) {
|
||||
@@ -1855,45 +1808,6 @@ function getVenvPython(venvRoot) {
|
||||
// normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is
|
||||
// needed.
|
||||
|
||||
function getVenvSitePackagesEntries(venvRoot) {
|
||||
const entries = []
|
||||
|
||||
if (!venvRoot) {
|
||||
return entries
|
||||
}
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
const version = (() => {
|
||||
try {
|
||||
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
||||
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
|
||||
|
||||
return match ? match[1].trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (version) {
|
||||
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function makeDashboardReadyFile() {
|
||||
const dir = path.join(app.getPath('userData'), 'backend-ready')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
@@ -2326,6 +2240,7 @@ function isShimLocked(shimPath) {
|
||||
if (!IS_WINDOWS) {
|
||||
return false
|
||||
}
|
||||
|
||||
let fd
|
||||
|
||||
try {
|
||||
@@ -2463,6 +2378,7 @@ async function releaseBackendLock(updateRoot, tag) {
|
||||
for (const pid of stragglers) {
|
||||
forceKillProcessTree(pid)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
@@ -2662,7 +2578,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
||||
const haveRealInstall =
|
||||
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
|
||||
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch)
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
@@ -2757,6 +2673,7 @@ function runningAppBundle() {
|
||||
if (!IS_MAC) {
|
||||
return null
|
||||
}
|
||||
|
||||
let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
@@ -3183,6 +3100,7 @@ function resolveRendererIndex() {
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
|
||||
// Nothing on disk. A packaged build with no renderer bundle blank-pages with
|
||||
// a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause
|
||||
// and the fix before Electron loads the missing file.
|
||||
@@ -3231,6 +3149,7 @@ function resolveHermesCwd() {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = path.resolve(String(candidate))
|
||||
|
||||
if (isPackagedInstallPath(resolved)) {
|
||||
@@ -3755,6 +3674,7 @@ function fetchJson(url, token, options: any = {}) {
|
||||
if (body) {
|
||||
req.write(body)
|
||||
}
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -3844,6 +3764,7 @@ function fetchPublicJson(url, options: any = {}) {
|
||||
if (body) {
|
||||
req.write(body)
|
||||
}
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -3961,6 +3882,7 @@ function cacheTitle(key, title) {
|
||||
if (titleCache.size >= TITLE_CACHE_LIMIT) {
|
||||
titleCache.delete(titleCache.keys().next().value)
|
||||
}
|
||||
|
||||
titleCache.set(key, title)
|
||||
}
|
||||
|
||||
@@ -4015,6 +3937,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
|
||||
if (bytes >= TITLE_BYTE_BUDGET) {
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const remaining = TITLE_BYTE_BUDGET - bytes
|
||||
const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer
|
||||
@@ -4027,6 +3950,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
|
||||
if (!chunks.length) {
|
||||
return resolve('')
|
||||
}
|
||||
|
||||
resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8')))
|
||||
})
|
||||
})
|
||||
@@ -4036,6 +3960,7 @@ function getLinkTitleSession() {
|
||||
if (linkTitleSession || !app.isReady()) {
|
||||
return linkTitleSession
|
||||
}
|
||||
|
||||
linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false })
|
||||
linkTitleSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) })
|
||||
@@ -4078,6 +4003,7 @@ function runRenderTitleJob(rawUrl) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (hardTimer) {
|
||||
@@ -4087,6 +4013,7 @@ function runRenderTitleJob(rawUrl) {
|
||||
if (graceTimer) {
|
||||
clearTimeout(graceTimer)
|
||||
}
|
||||
|
||||
const value = (title || '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
try {
|
||||
@@ -4112,6 +4039,7 @@ function runRenderTitleJob(rawUrl) {
|
||||
if (graceTimer) {
|
||||
clearTimeout(graceTimer)
|
||||
}
|
||||
|
||||
graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS)
|
||||
}
|
||||
|
||||
@@ -4193,6 +4121,7 @@ async function resourceBufferFromUrl(rawUrl) {
|
||||
if (!match) {
|
||||
throw new Error('Invalid data URL')
|
||||
}
|
||||
|
||||
const mimeType = match[1] || 'application/octet-stream'
|
||||
const encoded = match[3] || ''
|
||||
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
||||
@@ -4241,6 +4170,7 @@ async function copyImageFromUrl(rawUrl) {
|
||||
if (image.isEmpty()) {
|
||||
throw new Error('Could not read image')
|
||||
}
|
||||
|
||||
clipboard.writeImage(image)
|
||||
}
|
||||
|
||||
@@ -4256,6 +4186,7 @@ async function saveImageFromUrl(rawUrl) {
|
||||
if (result.canceled || !result.filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(result.filePath, buffer)
|
||||
|
||||
return true
|
||||
@@ -4390,11 +4321,13 @@ function sendPreviewFileChanged(payload) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:preview-file-changed', payload)
|
||||
}
|
||||
|
||||
@@ -4415,12 +4348,14 @@ async function watchPreviewFile(rawUrl) {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
|
||||
if (!fileExists(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() })
|
||||
}, PREVIEW_WATCH_DEBOUNCE_MS)
|
||||
})
|
||||
@@ -4430,6 +4365,7 @@ async function watchPreviewFile(rawUrl) {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
watcher.close()
|
||||
}
|
||||
})
|
||||
@@ -4504,11 +4440,13 @@ function sendBackendExit(payload) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:backend-exit', payload)
|
||||
}
|
||||
|
||||
@@ -4516,11 +4454,13 @@ function sendClosePreviewRequested() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:close-preview-requested')
|
||||
}
|
||||
|
||||
@@ -4531,11 +4471,13 @@ function sendPowerResume() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:power-resume')
|
||||
}
|
||||
|
||||
@@ -4545,6 +4487,7 @@ function registerPowerResumeListeners() {
|
||||
if (powerResumeRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
powerResumeRegistered = true
|
||||
|
||||
try {
|
||||
@@ -4566,16 +4509,19 @@ function sendOpenUpdatesRequested() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:open-updates')
|
||||
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
@@ -4583,11 +4529,13 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = getWindowState()
|
||||
|
||||
if (typeof nextIsFullscreen === 'boolean') {
|
||||
@@ -4732,6 +4680,7 @@ function installDevToolsShortcut(window) {
|
||||
if (!isInspectShortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
toggleDevTools(window)
|
||||
})
|
||||
@@ -4756,22 +4705,22 @@ function installPreviewShortcut(window) {
|
||||
// process owns setZoomLevel, so we mirror each change into localStorage and
|
||||
// read it back on did-finish-load to re-apply after reloads or crash recovery.
|
||||
import {
|
||||
clampZoomLevel,
|
||||
applyZoomLevel,
|
||||
installZoomReassertOnWindowEvents,
|
||||
percentToZoomLevel,
|
||||
ZOOM_STORAGE_KEY,
|
||||
zoomLevelToPercent
|
||||
zoomLevelToPercent,
|
||||
zoomWiringForWindowKind
|
||||
} from './zoom'
|
||||
|
||||
function setAndPersistZoomLevel(window, zoomLevel) {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const next = clampZoomLevel(zoomLevel)
|
||||
window.webContents.setZoomLevel(next)
|
||||
// Keep any open settings UI in sync, including changes made via the
|
||||
// keyboard shortcuts or the View menu.
|
||||
window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })
|
||||
|
||||
// Apply + notify in one funnel so the settings UI stays in sync, including
|
||||
// changes made via the keyboard shortcuts or the View menu.
|
||||
const next = applyZoomLevel(window.webContents, zoomLevel)
|
||||
window.webContents
|
||||
.executeJavaScript(
|
||||
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
|
||||
@@ -4783,6 +4732,7 @@ function restorePersistedZoomLevel(window) {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
window.webContents
|
||||
.executeJavaScript(
|
||||
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
|
||||
@@ -4791,8 +4741,10 @@ function restorePersistedZoomLevel(window) {
|
||||
if (stored == null || !window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const level = clampZoomLevel(Number(stored))
|
||||
window.webContents.setZoomLevel(level)
|
||||
|
||||
// Notify the renderer too — otherwise the Appearance UI Scale control
|
||||
// can stay stuck at 100% even though the window zoom was restored.
|
||||
applyZoomLevel(window.webContents, Number(stored))
|
||||
})
|
||||
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
|
||||
}
|
||||
@@ -4867,6 +4819,7 @@ function installContextMenu(window) {
|
||||
if (template.length) {
|
||||
template.push({ type: 'separator' })
|
||||
}
|
||||
|
||||
template.push(
|
||||
{
|
||||
label: 'Open Link',
|
||||
@@ -5020,6 +4973,7 @@ function getOauthSession() {
|
||||
if (oauthSession || !app.isReady()) {
|
||||
return oauthSession
|
||||
}
|
||||
|
||||
oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION)
|
||||
|
||||
return oauthSession
|
||||
@@ -5035,6 +4989,7 @@ async function hasOauthSessionCookie(baseUrl) {
|
||||
if (!sess) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsed = new URL(baseUrl)
|
||||
|
||||
try {
|
||||
@@ -5067,6 +5022,7 @@ async function hasLiveOauthSession(baseUrl) {
|
||||
if (!sess) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsed = new URL(baseUrl)
|
||||
|
||||
try {
|
||||
@@ -5149,6 +5105,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (pollTimer) {
|
||||
@@ -5318,6 +5275,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
const statusCode = res.statusCode || 500
|
||||
@@ -5356,6 +5314,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
@@ -5363,6 +5322,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
|
||||
if (body) {
|
||||
request.write(body)
|
||||
}
|
||||
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
@@ -5768,6 +5728,7 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
|
||||
} = {
|
||||
mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local'
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
|
||||
if (url) {
|
||||
@@ -6169,7 +6130,7 @@ async function fetchJsonForProfile(profile, path) {
|
||||
}
|
||||
|
||||
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
|
||||
async function requestJsonForProfile(profile: string, path: string, method: Method, body?: string) {
|
||||
async function requestJsonForProfile(profile: string, path: string, method: string, body?: string) {
|
||||
const conn = await ensureBackend(profile)
|
||||
const url = `${conn.baseUrl}${path}`
|
||||
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
||||
@@ -6322,19 +6283,7 @@ function resetBootProgressForReconnect() {
|
||||
}
|
||||
|
||||
function stopBackendChild(child) {
|
||||
if (!child || child.killed) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
||||
forceKillProcessTree(child.pid)
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
stopBackendChildImpl(child, { forceKillProcessTree, isWindows: IS_WINDOWS })
|
||||
}
|
||||
|
||||
// Soft gateway-mode apply: tear down the primary without resetting boot UI or
|
||||
@@ -6469,6 +6418,7 @@ function touchPoolBackend(profile) {
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const entry = backendPool.get(key)
|
||||
|
||||
if (entry) {
|
||||
@@ -6484,6 +6434,7 @@ function evictLruPoolBackends(keep) {
|
||||
if (backendPool.size <= keep) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const evictable = [...backendPool.entries()]
|
||||
@@ -6496,6 +6447,7 @@ function evictLruPoolBackends(keep) {
|
||||
if (removable <= 0) {
|
||||
break
|
||||
}
|
||||
|
||||
rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`)
|
||||
stopPoolBackend(profile)
|
||||
removable -= 1
|
||||
@@ -6506,6 +6458,7 @@ function startPoolIdleReaper() {
|
||||
if (poolIdleReaper) {
|
||||
return
|
||||
}
|
||||
|
||||
poolIdleReaper = setInterval(() => {
|
||||
const now = Date.now()
|
||||
|
||||
@@ -6658,6 +6611,7 @@ function stopPoolBackend(profile) {
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
backendPool.delete(profile)
|
||||
stopBackendChild(entry.process)
|
||||
}
|
||||
@@ -6668,6 +6622,7 @@ async function teardownPoolBackendAndWait(profile) {
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
backendPool.delete(profile)
|
||||
|
||||
stopBackendChild(entry.process)
|
||||
@@ -6681,59 +6636,38 @@ function stopAllPoolBackends() {
|
||||
}
|
||||
}
|
||||
|
||||
function profileNameFromDeleteRequest(request) {
|
||||
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
let raw = ''
|
||||
|
||||
try {
|
||||
raw = decodeURIComponent(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = raw.trim()
|
||||
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (name.toLowerCase() === 'default') {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
// Returns the profile name whose backend was torn down, or null when the
|
||||
// request is not a profile-delete. The caller uses this to skip ensureBackend
|
||||
// for the just-torn-down profile — otherwise ensureBackend respawns a pool
|
||||
// backend whose ensure_hermes_home() recreates the deleted profile directory.
|
||||
//
|
||||
// The routing *decision* (which branch fires, what profile name gets
|
||||
// returned) lives in the pure decideProfileDeleteAction() in
|
||||
// profile-delete-routing.ts; this function only performs the side effects
|
||||
// that decision calls for.
|
||||
async function prepareProfileDeleteRequest(request) {
|
||||
const profile = profileNameFromDeleteRequest(request)
|
||||
|
||||
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
|
||||
const decision = decideProfileDeleteAction(profile, {
|
||||
isDefaultProfile: p => p === 'default',
|
||||
isValidProfileName: p => PROFILE_NAME_RE.test(p),
|
||||
primaryProfileKey
|
||||
})
|
||||
|
||||
if (decision.action === 'noop') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (profile === primaryProfileKey()) {
|
||||
if (decision.action === 'teardown-primary') {
|
||||
writeActiveDesktopProfile('default')
|
||||
await teardownPrimaryBackendAndWait()
|
||||
|
||||
return profile
|
||||
return decision.profile
|
||||
}
|
||||
|
||||
await teardownPoolBackendAndWait(profile)
|
||||
await teardownPoolBackendAndWait(decision.profile)
|
||||
|
||||
return profile
|
||||
return decision.profile
|
||||
}
|
||||
|
||||
async function startHermes() {
|
||||
@@ -7014,6 +6948,7 @@ function focusWindow(win) {
|
||||
if (!win.isVisible()) {
|
||||
win.show()
|
||||
}
|
||||
|
||||
win.focus()
|
||||
}
|
||||
|
||||
@@ -7060,7 +6995,7 @@ function spawnSecondaryWindow({
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
|
||||
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
@@ -7170,9 +7105,9 @@ function spawnPetOverlayWindow(bounds) {
|
||||
// Not supported everywhere — best effort.
|
||||
}
|
||||
|
||||
// Pet overlay opts out of global UI zoom (see wireCommonWindowHandlers): it
|
||||
// Pet overlay opts out of global UI zoom (see zoomWiringForWindowKind): it
|
||||
// owns its window-fit + scale, and inheriting zoom would crop the sprite.
|
||||
wireCommonWindowHandlers(win, { zoom: false })
|
||||
wireCommonWindowHandlers(win, zoomWiringForWindowKind('petOverlay'))
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
@@ -7304,7 +7239,7 @@ function createWindow() {
|
||||
// window-all-closed from quitting on Windows/Linux).
|
||||
mainWindow.on('closed', () => closePetOverlay())
|
||||
|
||||
wireCommonWindowHandlers(mainWindow)
|
||||
wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat'))
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||||
@@ -7451,6 +7386,7 @@ ipcMain.on('hermes:zoom:set-percent', (event, percent) => {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent)))
|
||||
})
|
||||
|
||||
@@ -7918,7 +7854,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
// backend instead of spawning a fresh pool backend. A freshly spawned
|
||||
// backend calls ensure_hermes_home() which recreates the profile directory,
|
||||
// defeating the deletion and leaving a zombie process.
|
||||
const routeProfile = tornDownProfile ? null : profile
|
||||
const routeProfile = resolveRouteProfile(tornDownProfile, profile)
|
||||
const connection = await ensureBackend(routeProfile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
||||
@@ -7952,6 +7888,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
@@ -7967,6 +7904,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
focusWindow(mainWindow)
|
||||
|
||||
if (payload?.sessionId) {
|
||||
@@ -7977,6 +7915,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const action = actions[index]
|
||||
|
||||
if (action?.id) {
|
||||
@@ -8527,6 +8466,10 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
|
||||
listBaseBranches(repoPath, resolveGitBinary())
|
||||
)
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
@@ -8774,6 +8717,7 @@ async function getUninstallSummary() {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
@@ -8968,6 +8912,7 @@ function handleDeepLink(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
let parsed
|
||||
|
||||
try {
|
||||
@@ -8997,6 +8942,7 @@ function handleDeepLink(url) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
|
||||
mainWindow.focus()
|
||||
mainWindow.webContents.send('hermes:deep-link', payload)
|
||||
rememberLog(`[deeplink] delivered ${kind}/${name}`)
|
||||
@@ -9053,6 +8999,7 @@ if (!_gotSingleInstanceLock) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Regression coverage for the OAuth-session Electron net.request path.
|
||||
*
|
||||
* Electron net rejects manual Content-Length/Host headers with
|
||||
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
|
||||
* this guard is scoped to fetchJsonViaOauthSession only.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
function extractFetchJsonViaOauthSession() {
|
||||
const start = source.indexOf('function fetchJsonViaOauthSession')
|
||||
const end = source.indexOf('// Mint a single-use WS ticket', start)
|
||||
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
|
||||
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
|
||||
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
|
||||
const fn = extractFetchJsonViaOauthSession()
|
||||
|
||||
assert.match(fn, /electronNet\.request/)
|
||||
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
|
||||
assert.match(fn, /request\.write\(body\)/)
|
||||
})
|
||||
@@ -117,6 +117,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
|
||||
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
|
||||
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
|
||||
baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath),
|
||||
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
|
||||
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
|
||||
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const ELECTRON_DIR = import.meta.dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepareProfileDeleteRequest must return the torn-down profile name so the
|
||||
// caller can skip ensureBackend for that profile (issue #52279).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// Locate the function definition and its closing brace.
|
||||
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
|
||||
assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found')
|
||||
|
||||
// The function must contain "return profile" (pool and primary paths).
|
||||
const fnBody = source.slice(fnStart, fnStart + 800)
|
||||
const returnProfileCount = (fnBody.match(/return profile/g) || []).length
|
||||
assert.ok(
|
||||
returnProfileCount >= 2,
|
||||
`expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}`
|
||||
)
|
||||
|
||||
// The early-exit guard must return null (not void/undefined).
|
||||
assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined')
|
||||
})
|
||||
|
||||
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The handler must capture prepareProfileDeleteRequest's return value.
|
||||
assert.match(
|
||||
source,
|
||||
/const tornDownProfile = await prepareProfileDeleteRequest\(request\)/,
|
||||
'handler should capture the return value of prepareProfileDeleteRequest'
|
||||
)
|
||||
|
||||
// The handler must use the return value to skip ensureBackend for the
|
||||
// torn-down profile, routing to the primary (null) instead.
|
||||
assert.match(
|
||||
source,
|
||||
/const routeProfile = tornDownProfile \? null : profile/,
|
||||
'handler should route to primary backend when a profile was just torn down'
|
||||
)
|
||||
|
||||
// ensureBackend must be called with the conditional route profile.
|
||||
assert.match(
|
||||
source,
|
||||
/const connection = await ensureBackend\(routeProfile\)/,
|
||||
'handler should pass routeProfile (not raw profile) to ensureBackend'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// profileNameFromDeleteRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('profileNameFromDeleteRequest parses a DELETE /api/profiles/<name> path', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/worker' }), 'worker')
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest lowercases the profile name', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/Worker' }), 'worker')
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for non-DELETE methods', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'GET', path: '/api/profiles/worker' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null when the path does not match', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/sessions' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for an empty/whitespace name', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%20' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for an undecodable path segment', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%E0%A4%A' }), null)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decideProfileDeleteAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const deps = {
|
||||
isDefaultProfile: p => p === 'default',
|
||||
isValidProfileName: p => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(p),
|
||||
primaryProfileKey: () => 'primary-profile'
|
||||
}
|
||||
|
||||
test('decideProfileDeleteAction is a noop for the default profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('default', deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction is a noop for null (no profile parsed)', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction(null, deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction is a noop for an invalid profile name', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('Not Valid!', deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction tears down the primary backend for the primary profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('primary-profile', deps), {
|
||||
action: 'teardown-primary',
|
||||
profile: 'primary-profile'
|
||||
})
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction tears down the pool backend for any other valid profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('worker', deps), { action: 'teardown-pool', profile: 'worker' })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveRouteProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('resolveRouteProfile routes to the primary backend (null) when a profile was torn down', () => {
|
||||
assert.equal(resolveRouteProfile('worker', 'other-profile'), null)
|
||||
})
|
||||
|
||||
test('resolveRouteProfile passes the requested profile through when nothing was torn down', () => {
|
||||
assert.equal(resolveRouteProfile(null, 'other-profile'), 'other-profile')
|
||||
})
|
||||
|
||||
test('resolveRouteProfile passes through undefined when nothing was torn down and no profile was requested', () => {
|
||||
assert.equal(resolveRouteProfile(null, undefined), undefined)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
// Profile-delete routing logic for the `hermes:api` IPC handler.
|
||||
//
|
||||
// When the renderer issues DELETE /api/profiles/<name>, the handler must
|
||||
// tear down that profile's backend (primary window backend or pool backend)
|
||||
// and then route the *next* request away from the just-deleted profile's
|
||||
// pool backend -- spawning a fresh one would call ensure_hermes_home() and
|
||||
// recreate the profile directory the delete just removed, leaving a zombie
|
||||
// process behind (issue #52279).
|
||||
//
|
||||
// These helpers are pure so they can be unit-tested without Electron.
|
||||
|
||||
/**
|
||||
* Parse a `hermes:api` request into the profile name a DELETE targets, or
|
||||
* null when the request is not a profile-delete at all (wrong method, wrong
|
||||
* path, empty/invalid name).
|
||||
*/
|
||||
export function profileNameFromDeleteRequest(request) {
|
||||
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
let raw = ''
|
||||
|
||||
try {
|
||||
raw = decodeURIComponent(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = raw.trim()
|
||||
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (name.toLowerCase() === 'default') {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
export type ProfileDeleteAction = 'noop' | 'teardown-primary' | 'teardown-pool'
|
||||
|
||||
export interface ProfileDeleteDecision {
|
||||
action: ProfileDeleteAction
|
||||
profile: string | null
|
||||
}
|
||||
|
||||
export interface ProfileDeleteDecisionDeps {
|
||||
isDefaultProfile: (profile: string) => boolean
|
||||
isValidProfileName: (profile: string) => boolean
|
||||
primaryProfileKey: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision logic for prepareProfileDeleteRequest: given the parsed
|
||||
* profile name (or null), decide which side-effecting branch the caller
|
||||
* should take and what profile name it should ultimately report as
|
||||
* torn-down. No I/O, no async -- the caller performs the actual teardown
|
||||
* based on `action`.
|
||||
*/
|
||||
export function decideProfileDeleteAction(
|
||||
profile: string | null,
|
||||
deps: ProfileDeleteDecisionDeps
|
||||
): ProfileDeleteDecision {
|
||||
if (!profile || deps.isDefaultProfile(profile) || !deps.isValidProfileName(profile)) {
|
||||
return { action: 'noop', profile: null }
|
||||
}
|
||||
|
||||
if (profile === deps.primaryProfileKey()) {
|
||||
return { action: 'teardown-primary', profile }
|
||||
}
|
||||
|
||||
return { action: 'teardown-pool', profile }
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the next `hermes:api` request away from the primary/window backend
|
||||
* whenever a profile was just torn down -- otherwise ensureBackend would
|
||||
* spawn a fresh pool backend for the deleted profile, whose
|
||||
* ensure_hermes_home() recreates the directory the delete just removed.
|
||||
*/
|
||||
export function resolveRouteProfile(
|
||||
tornDownProfile: string | null,
|
||||
profile: string | undefined
|
||||
): string | null | undefined {
|
||||
return tornDownProfile ? null : profile
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
MACOS_TAHOE_DARWIN_MAJOR,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveBehindCount, shouldCountCommits } from './update-count'
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
isPidAlive,
|
||||
markerPath,
|
||||
@@ -40,6 +41,7 @@ const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" =
|
||||
|
||||
const DEAD: typeof process.kill = () => {
|
||||
const err = new Error('no such process')
|
||||
|
||||
;(err as any).code = 'ESRCH'
|
||||
throw err
|
||||
}
|
||||
@@ -93,6 +95,7 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => {
|
||||
test('isPidAlive: EPERM counts as alive (process owned by another user)', () => {
|
||||
const eperm = () => {
|
||||
const err = new Error('operation not permitted')
|
||||
|
||||
;(err as any).code = 'EPERM'
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildRelaunchScript,
|
||||
|
||||
@@ -63,6 +63,7 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) {
|
||||
if (!execPath || !updateRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
|
||||
const unpacked = path.join(releaseDir, unpackedDirName(platform))
|
||||
const normalizedExec = path.resolve(String(execPath))
|
||||
@@ -116,6 +117,7 @@ function sandboxPreflight(unpackedDir, statSync) {
|
||||
if (!unpackedDir) {
|
||||
return { ok: false, reason: 'no-unpacked-dir', path: null }
|
||||
}
|
||||
|
||||
const sandboxPath = path.join(unpackedDir, 'chrome-sandbox')
|
||||
let st
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
canonicalGitHubRemote,
|
||||
|
||||
@@ -22,6 +22,7 @@ function canonicalGitHubRemote(url) {
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let value = String(url).trim()
|
||||
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace'
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
computeWindowOptions,
|
||||
@@ -118,8 +119,8 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
|
||||
|
||||
// ─── debounce ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('debounce coalesces a burst into one trailing run', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
test('debounce coalesces a burst into one trailing run', () => {
|
||||
vi.useFakeTimers()
|
||||
let calls = 0
|
||||
|
||||
const d = debounce(() => {
|
||||
@@ -130,14 +131,16 @@ test('debounce coalesces a burst into one trailing run', t => {
|
||||
d()
|
||||
d()
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(249)
|
||||
vi.advanceTimersByTime(249)
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(1)
|
||||
vi.advanceTimersByTime(1)
|
||||
assert.equal(calls, 1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
test('debounce.flush runs now and cancels the pending timer', () => {
|
||||
vi.useFakeTimers()
|
||||
let calls = 0
|
||||
|
||||
const d = debounce(() => {
|
||||
@@ -147,6 +150,8 @@ test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
d()
|
||||
d.flush()
|
||||
assert.equal(calls, 1)
|
||||
t.mock.timers.tick(1000)
|
||||
vi.advanceTimersByTime(1000)
|
||||
assert.equal(calls, 1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ function onScreen(bounds, displays) {
|
||||
if (!a) {
|
||||
return false
|
||||
}
|
||||
|
||||
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
|
||||
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { stopBackendChild } from './backend-child'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
test('hiddenWindowsChildOptions adds windowsHide:true on Windows when unset', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({}, true), { windowsHide: true })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions preserves an existing windowsHide:false on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: false }, true), { windowsHide: false })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions preserves an existing windowsHide:true on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: true }, true), { windowsHide: true })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions leaves options unchanged off Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({}, false), {})
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ stdio: 'ignore' }, false), { stdio: 'ignore' })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions merges windowsHide alongside other options on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, true), {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true
|
||||
})
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions defaults isWindows from process.platform when omitted', () => {
|
||||
const result = hiddenWindowsChildOptions({})
|
||||
const expectedHide = process.platform === 'win32'
|
||||
|
||||
assert.equal(Boolean(result.windowsHide), expectedHide)
|
||||
})
|
||||
|
||||
function makeChild(overrides: Partial<{ pid: number | null; killed: boolean }> = {}) {
|
||||
const calls: string[] = []
|
||||
|
||||
return {
|
||||
calls,
|
||||
child: {
|
||||
kill: (signal: string) => {
|
||||
calls.push(signal)
|
||||
},
|
||||
killed: overrides.killed ?? false,
|
||||
pid: 'pid' in overrides ? overrides.pid : 1234
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('stopBackendChild tree-kills on Windows when the child has a pid', () => {
|
||||
const { child, calls } = makeChild({ pid: 4242 })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(treeKillCalls, [4242])
|
||||
assert.deepEqual(calls, [], 'SIGTERM must not be sent when the Windows tree-kill path is taken')
|
||||
})
|
||||
|
||||
test('stopBackendChild sends SIGTERM on non-Windows platforms', () => {
|
||||
const { child, calls } = makeChild({ pid: 4242 })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: false
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, ['SIGTERM'])
|
||||
assert.deepEqual(treeKillCalls, [], 'tree-kill must not run off Windows')
|
||||
})
|
||||
|
||||
test('stopBackendChild falls back to SIGTERM on Windows when the pid is not an integer', () => {
|
||||
const { child, calls } = makeChild({ pid: null })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, ['SIGTERM'])
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild is a no-op for an already-killed child', () => {
|
||||
const { child, calls } = makeChild({ killed: true })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [])
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild is a no-op for a null/undefined child', () => {
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
stopBackendChild(null, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true })
|
||||
stopBackendChild(undefined, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true })
|
||||
})
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild swallows errors thrown by the kill strategy', () => {
|
||||
const child = {
|
||||
kill: () => {
|
||||
throw new Error('ESRCH: no such process')
|
||||
},
|
||||
killed: false,
|
||||
pid: 99
|
||||
}
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: () => {},
|
||||
isWindows: false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* windows-child-options.ts
|
||||
*
|
||||
* Shared helper for opting Windows child processes (spawn/execFileSync) into
|
||||
* a hidden console. Windows spawns a visible console window per child by
|
||||
* default; every desktop-launched helper process (git, curl, taskkill, the
|
||||
* backend itself, the bootstrap PowerShell runner, ...) needs `windowsHide:
|
||||
* true` so the user doesn't see consoles flashing on screen.
|
||||
*
|
||||
* Extracted into its own dependency-free module (no electron import) so it
|
||||
* can be unit-tested directly for both platforms without reading source
|
||||
* text, and so main.ts and bootstrap-runner.ts share exactly one
|
||||
* implementation instead of each defining their own copy.
|
||||
*/
|
||||
|
||||
import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
|
||||
|
||||
/**
|
||||
* Merge `windowsHide: true` into `options` when running on Windows, unless
|
||||
* the caller already specified a `windowsHide` value (which is preserved
|
||||
* as-is, including an explicit `false` for cases that intentionally want a
|
||||
* visible/interactive console). No-op on non-Windows platforms.
|
||||
*
|
||||
* @param options - spawn/execFileSync options to (possibly) augment.
|
||||
* @param isWindows - defaults to the real platform check; injectable for
|
||||
* tests so both branches can be exercised without mocking process.platform.
|
||||
*/
|
||||
export function hiddenWindowsChildOptions(
|
||||
options: any = {},
|
||||
isWindows: boolean = process.platform === 'win32'
|
||||
): ExecFileSyncOptionsWithStringEncoding {
|
||||
if (!isWindows || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options as any
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true } as any
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// TODO FIXME these tests all grep source code for specific things. This is an antipattern.
|
||||
// Tests should NEVER read src, only assert behavior.
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
const match = needle instanceof RegExp ? needle.exec(source) : null
|
||||
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
|
||||
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
||||
const snippet = source.slice(index, index + 700)
|
||||
assert.match(
|
||||
snippet,
|
||||
/hiddenWindowsChildOptions\(/,
|
||||
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
|
||||
)
|
||||
}
|
||||
|
||||
test('desktop background child processes opt into hidden Windows consoles', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options: any = \{\}\)/)
|
||||
|
||||
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
|
||||
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
||||
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
|
||||
requireHiddenChildOptions(source, "spawn('curl'")
|
||||
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
|
||||
|
||||
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/)
|
||||
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
|
||||
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
|
||||
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/)
|
||||
})
|
||||
|
||||
test('desktop backend launches console python so child consoles are inherited, not pythonw', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The flash fix is structural: the backend runs as a console-subsystem
|
||||
// python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it
|
||||
// owns ONE windowless console that every descendant spawn inherits. Launching
|
||||
// it as GUI-subsystem pythonw.exe is what made each child allocate (and flash)
|
||||
// its own console, so the backend command must never be pythonw.
|
||||
assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe')
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/function getNoConsoleVenvPython\b/,
|
||||
'pythonw-conversion helper should be gone; console python is launched directly'
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/function applyWindowsNoConsoleSpawnHints\b/,
|
||||
'pythonw spawn-hint rewriter should be gone'
|
||||
)
|
||||
|
||||
// Console python restores stdout, so the port is announced on the normal
|
||||
// HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set.
|
||||
assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path')
|
||||
|
||||
// Both desktop backend launches must still go through hiddenWindowsChildOptions
|
||||
// so the single backend console is created windowless.
|
||||
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
})
|
||||
|
||||
test('desktop backend teardown tree-kills Windows backend descendants', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
const helperIndex = source.indexOf('function stopBackendChild(child)')
|
||||
assert.notEqual(helperIndex, -1, 'missing backend teardown helper')
|
||||
const helperSnippet = source.slice(helperIndex, helperIndex + 500)
|
||||
assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/)
|
||||
assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/)
|
||||
assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/)
|
||||
|
||||
const resetIndex = source.indexOf('function resetHermesConnection()')
|
||||
assert.notEqual(resetIndex, -1, 'missing resetHermesConnection')
|
||||
const resetSnippet = source.slice(resetIndex, resetIndex + 300)
|
||||
assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/)
|
||||
assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
|
||||
|
||||
const quitIndex = source.indexOf("app.on('before-quit'")
|
||||
assert.notEqual(quitIndex, -1, 'missing before-quit handler')
|
||||
const quitSnippet = source.slice(quitIndex, quitIndex + 900)
|
||||
assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/)
|
||||
assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||
const source = readElectronFile('bootstrap-runner.ts')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
// Unit tests for the pure Windows `hermes` resolution helpers extracted from
|
||||
// main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
|
||||
// unwrapWindowsVenvHermesCommand(). These pin the two Windows resolution bugs
|
||||
// that caused desktop reinstall loops:
|
||||
// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the
|
||||
// empty extension, or an extensionless Git-Bash `hermes` shim shadows
|
||||
// the real hermes.cmd/hermes.exe.
|
||||
// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install
|
||||
// signal), not just the hermes.exe console-script shim, or healthy
|
||||
// installs get forced into a destructive --repair.
|
||||
// 3. resolveVenvHermesCommand() — must probe the venv python via
|
||||
// canImportHermesCli() before trusting it, or a broken venv gets
|
||||
// re-selected forever instead of falling through to bootstrap.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
|
||||
test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => {
|
||||
const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true)
|
||||
|
||||
assert.deepEqual(extensions, ['.COM', '.EXE', '.BAT', '.CMD', ''])
|
||||
assert.equal(extensions[extensions.length - 1], '', 'empty extension must be last, not first')
|
||||
assert.notEqual(extensions[0], '', 'the buggy empty-extension-first order must not return')
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: defaults to .COM;.EXE;.BAT;.CMD when PATHEXT is unset on Windows', () => {
|
||||
assert.deepEqual(buildPathExtCandidates(undefined, true), ['.COM', '.EXE', '.BAT', '.CMD', ''])
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: respects a custom PATHEXT, still empty-last', () => {
|
||||
assert.deepEqual(buildPathExtCandidates('.EXE;.PS1', true), ['.EXE', '.PS1', ''])
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: non-Windows only tries the bare name', () => {
|
||||
assert.deepEqual(buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', false), [''])
|
||||
assert.deepEqual(buildPathExtCandidates(undefined, false), [''])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main'])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main'])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2'])
|
||||
assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2'])
|
||||
})
|
||||
|
||||
function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) {
|
||||
return {
|
||||
isWindows: true,
|
||||
isCommandScript: () => false,
|
||||
fileExists: () => true,
|
||||
directoryExists: () => false,
|
||||
canImportHermesCli: () => true,
|
||||
getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`,
|
||||
getVenvSitePackagesEntries: () => [],
|
||||
buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }),
|
||||
hermesHome: '/fake/hermes-home',
|
||||
resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'),
|
||||
dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/',
|
||||
basename: (p: string) => p.slice(p.lastIndexOf('/') + 1),
|
||||
rememberLog: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveVenvHermesCommand: returns null off Windows', () => {
|
||||
const deps = makeDeps({ isWindows: false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => {
|
||||
const deps = makeDeps({ isCommandScript: () => true })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => {
|
||||
const deps = makeDeps({ fileExists: () => false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => {
|
||||
let probed = false
|
||||
|
||||
const deps = makeDeps({
|
||||
canImportHermesCli: (python: string) => {
|
||||
probed = true
|
||||
assert.equal(python, '/root/venv/Scripts/python.exe')
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
|
||||
|
||||
assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever')
|
||||
assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap')
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => {
|
||||
const deps = makeDeps()
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
|
||||
|
||||
assert.ok(result, 'a passing probe must return a backend descriptor, not null')
|
||||
assert.equal(result.command, '/root/venv/Scripts/python.exe')
|
||||
assert.deepEqual(result.args, ['-m', 'hermes_cli.main', 'serve', '--port', '0'])
|
||||
assert.equal(result.bootstrap, false)
|
||||
assert.equal(result.kind, 'python')
|
||||
assert.equal(result.shell, false)
|
||||
assert.deepEqual(result.env, { FAKE_ENV: '1' })
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
|
||||
})
|
||||
|
||||
// ── getVenvSitePackagesEntries ─────────────────────────────────────────────
|
||||
|
||||
test('getVenvSitePackagesEntries: returns Lib/site-packages on Windows when it exists', () => {
|
||||
const expected = path.join('C:\\venv', 'Lib', 'site-packages')
|
||||
|
||||
const result = getVenvSitePackagesEntries('C:\\venv', {
|
||||
isWindows: true,
|
||||
directoryExists: p => p === expected
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [expected])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on Windows when site-packages does not exist', () => {
|
||||
const result = getVenvSitePackagesEntries('C:\\venv', {
|
||||
isWindows: true,
|
||||
directoryExists: () => false
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: p => p === '/venv/lib/python3.12/site-packages',
|
||||
readFile: () => 'version_info = 3.12.1\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, ['/venv/lib/python3.12/site-packages'])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => true,
|
||||
readFile: () => undefined
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg has no version_info', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => true,
|
||||
readFile: () => 'home = /usr/bin\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when version is present but site-packages dir is absent', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => false,
|
||||
readFile: () => 'version_info = 3.11\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty for a falsy venvRoot', () => {
|
||||
assert.deepEqual(getVenvSitePackagesEntries('', { isWindows: true, directoryExists: () => true }), [])
|
||||
assert.deepEqual(getVenvSitePackagesEntries(null, { isWindows: true, directoryExists: () => true }), [])
|
||||
assert.deepEqual(getVenvSitePackagesEntries(undefined, { isWindows: true, directoryExists: () => true }), [])
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* windows-hermes-path.ts
|
||||
*
|
||||
* Pure, dependency-injected pieces of Windows `hermes` resolution pulled out
|
||||
* of main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
|
||||
* unwrapWindowsVenvHermesCommand(). Each of the three functions here pins one
|
||||
* of the Windows resolution bugs that caused desktop reinstall loops:
|
||||
*
|
||||
* 1. buildPathExtCandidates() — findOnPath() tried the empty extension
|
||||
* FIRST, so an extensionless Git-Bash `hermes` shim shadowed the real
|
||||
* hermes.cmd/hermes.exe; the shim then failed the --version probe and
|
||||
* the desktop fell through to a spurious bootstrap/repair. The fix:
|
||||
* PATHEXT extensions first, empty extension LAST.
|
||||
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose
|
||||
* --update vs the destructive --repair by checking ONLY
|
||||
* venv\Scripts\hermes.exe (the console-script shim, written at the END
|
||||
* of venv setup and absent in interrupted states), so it escalated to a
|
||||
* full venv recreate even on healthy installs. The fix: gate on ANY
|
||||
* real-install signal, not just the shim.
|
||||
* 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned
|
||||
* the venv python with NO runtime probe (bypassing the caller's
|
||||
* --version check too), so a venv broken mid-update (e.g. missing
|
||||
* python-dotenv) was re-selected forever: Retry / "Repair install"
|
||||
* resolved the same dead interpreter instead of falling through to the
|
||||
* bootstrap installer. The fix: probe-before-trust.
|
||||
*
|
||||
* Kept in a standalone ts module (no Electron imports, dependencies passed
|
||||
* as parameters) so it can be unit-tested with `node --test` without
|
||||
* mocking Electron or the filesystem, same pattern as backend-probes.ts and
|
||||
* backend-command.ts.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Build the ordered list of extensions findOnPath() should try when
|
||||
* resolving a bare command name off PATH.
|
||||
*
|
||||
* On Windows this MUST try PATHEXT extensions (.COM;.EXE;.BAT;.CMD by
|
||||
* default) BEFORE the bare/empty-extension name: a real command resolves via
|
||||
* its .exe/.cmd per Windows command-resolution semantics, and an
|
||||
* extensionless file (e.g. a Git-Bash shell-script shim named `hermes`) must
|
||||
* not shadow `hermes.cmd`/`hermes.exe`. The empty entry is kept LAST so
|
||||
* callers that already include the extension (py.exe, pwsh.exe,
|
||||
* powershell.exe) still resolve.
|
||||
*
|
||||
* On non-Windows platforms there is no PATHEXT concept: only the bare name
|
||||
* is tried.
|
||||
*
|
||||
* @param {string | undefined} pathext - process.env.PATHEXT (or undefined).
|
||||
* @param {boolean} isWindows
|
||||
* @returns {string[]} extensions to try, in order, always ending in ''.
|
||||
*/
|
||||
export function buildPathExtCandidates(pathext: string | undefined, isWindows: boolean): string[] {
|
||||
if (!isWindows) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
return [...(pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the Windows bootstrap-recovery updater invocation: the gentle
|
||||
* in-place --update when ANY real-install signal is present, the
|
||||
* destructive --repair (full venv recreate) otherwise.
|
||||
*
|
||||
* haveRealInstall must be computed by the caller from ALL real-install
|
||||
* signals (venv python interpreter, venv hermes shim, bootstrap-complete
|
||||
* marker) — gating on just the hermes.exe console-script shim alone is the
|
||||
* regression this function's callers must avoid: that shim is written at
|
||||
* the END of venv setup and is absent in exactly the interrupted/quarantined
|
||||
* states this recovery exists to heal.
|
||||
*
|
||||
* @param {boolean} haveRealInstall
|
||||
* @param {string} branch
|
||||
* @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main'].
|
||||
*/
|
||||
export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] {
|
||||
return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the site-packages directory entries for a Python venv.
|
||||
*
|
||||
* On Windows, venv layout is `<venvRoot>/Lib/site-packages`.
|
||||
* On POSIX, it's `<venvRoot>/lib/python<version>/site-packages` where
|
||||
* `<version>` (e.g. `3.12`) is read from the venv's `pyvenv.cfg`
|
||||
* `version_info` field.
|
||||
*
|
||||
* Returns only directories that actually exist on disk. Returns an empty
|
||||
* array when `venvRoot` is falsy or no matching site-packages dir is found.
|
||||
*
|
||||
* Extracted from main.ts so the platform branching can be tested without
|
||||
* reading source text. `isWindows` and `directoryExists` are injectable;
|
||||
* `readFile` defaults to `fs.readFileSync` but can be overridden for tests.
|
||||
*/
|
||||
export function getVenvSitePackagesEntries(
|
||||
venvRoot: string | undefined | null,
|
||||
opts: {
|
||||
isWindows?: boolean
|
||||
directoryExists?: (p: string) => boolean
|
||||
readFile?: (p: string) => string | undefined
|
||||
} = {}
|
||||
): string[] {
|
||||
const entries: string[] = []
|
||||
|
||||
if (!venvRoot) {
|
||||
return entries
|
||||
}
|
||||
|
||||
const isWindows = opts.isWindows ?? process.platform === 'win32'
|
||||
|
||||
const directoryExists = opts.directoryExists ?? ((p: string) => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const readFile = opts.readFile ?? ((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
if (isWindows) {
|
||||
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
const cfg = readFile(path.join(venvRoot, 'pyvenv.cfg'))
|
||||
|
||||
const version = (() => {
|
||||
if (!cfg) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
|
||||
|
||||
return match ? match[1].trim() : null
|
||||
})()
|
||||
|
||||
if (version) {
|
||||
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export interface ResolveVenvHermesCommandDeps {
|
||||
isWindows: boolean
|
||||
isCommandScript: (command: string) => boolean
|
||||
fileExists: (filePath: string) => boolean
|
||||
directoryExists: (filePath: string) => boolean
|
||||
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean
|
||||
getVenvPython: (venvRoot: string) => string
|
||||
getVenvSitePackagesEntries: (venvRoot: string) => string[]
|
||||
buildDesktopBackendEnv: (opts: {
|
||||
hermesHome: string
|
||||
pythonPathEntries: string[]
|
||||
venvRoot: string
|
||||
}) => Record<string, string>
|
||||
hermesHome: string
|
||||
resolvePath: (...segments: string[]) => string
|
||||
dirname: (p: string) => string
|
||||
basename: (p: string) => string
|
||||
rememberLog?: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* If `command` is a Windows venv `hermes`/`hermes.exe` console-script shim
|
||||
* (i.e. `<venvRoot>/Scripts/hermes(.exe)`), resolve it to the underlying
|
||||
* venv python invoked as `python -m hermes_cli.main <backendArgs>` — but
|
||||
* ONLY after smoke-testing that interpreter with canImportHermesCli(). A
|
||||
* venv whose update died mid-`pip install` still has python.exe + hermes.exe
|
||||
* on disk, but the backend dies on its first import (e.g.
|
||||
* ModuleNotFoundError: dotenv) before the gateway ever binds. Returning it
|
||||
* unprobed also bypasses the caller's `--version` probe, so Retry/"Repair
|
||||
* install" re-resolves the same broken venv forever instead of falling
|
||||
* through to the bootstrap installer.
|
||||
*
|
||||
* Mirrors isActiveRuntimeUsable(): probes with the checkout on PYTHONPATH so
|
||||
* a healthy source-tree venv passes.
|
||||
*
|
||||
* Returns null when `command` is not a venv hermes shim, the underlying
|
||||
* python doesn't exist, or the import probe fails. Otherwise returns the
|
||||
* resolved backend descriptor.
|
||||
*/
|
||||
export function resolveVenvHermesCommand(
|
||||
command: string,
|
||||
backendArgs: string[],
|
||||
deps: ResolveVenvHermesCommandDeps
|
||||
): {
|
||||
label: string
|
||||
command: string
|
||||
args: string[]
|
||||
bootstrap: false
|
||||
env: Record<string, string>
|
||||
kind: 'python'
|
||||
root: string
|
||||
shell: false
|
||||
} | null {
|
||||
const {
|
||||
isWindows,
|
||||
isCommandScript,
|
||||
fileExists,
|
||||
directoryExists,
|
||||
canImportHermesCli,
|
||||
getVenvPython,
|
||||
getVenvSitePackagesEntries,
|
||||
buildDesktopBackendEnv,
|
||||
hermesHome,
|
||||
resolvePath,
|
||||
dirname,
|
||||
basename,
|
||||
rememberLog
|
||||
} = deps
|
||||
|
||||
if (!isWindows || !command || isCommandScript(command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = resolvePath(String(command))
|
||||
|
||||
if (!/^hermes(?:\.exe)?$/i.test(basename(resolved))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptsDir = dirname(resolved)
|
||||
|
||||
if (basename(scriptsDir).toLowerCase() !== 'scripts') {
|
||||
return null
|
||||
}
|
||||
|
||||
const venvRoot = dirname(scriptsDir)
|
||||
const python = getVenvPython(venvRoot)
|
||||
|
||||
if (!fileExists(python)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = dirname(venvRoot)
|
||||
|
||||
if (
|
||||
!canImportHermesCli(python, {
|
||||
env: {
|
||||
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(path.delimiter)
|
||||
}
|
||||
})
|
||||
) {
|
||||
rememberLog?.(
|
||||
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: `existing Hermes Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
root,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Regression guards for Windows `hermes` resolution in main.ts.
|
||||
//
|
||||
// main.ts has no module.exports, so these follow the repo's source-assertion
|
||||
// test pattern (see windows-child-process.test.ts). They pin the two Windows
|
||||
// resolution bugs that caused desktop reinstall loops:
|
||||
// 1. findOnPath() tried the empty extension FIRST, so an extensionless
|
||||
// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
|
||||
// shim then failed the --version probe and the desktop fell through to a
|
||||
// spurious bootstrap/repair.
|
||||
// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive
|
||||
// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script
|
||||
// shim, written at the END of venv setup and absent in interrupted
|
||||
// states), so it escalated to a full venv recreate even on healthy
|
||||
// installs.
|
||||
// 3. unwrapWindowsVenvHermesCommand() returned the venv python with NO
|
||||
// runtime probe (bypassing the caller's --version check too), so a venv
|
||||
// broken mid-update (e.g. missing python-dotenv) was re-selected forever:
|
||||
// Retry / "Repair install" resolved the same dead interpreter instead of
|
||||
// falling through to the bootstrap installer.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function readMain() {
|
||||
return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
|
||||
const source = readMain()
|
||||
// Fixed order: PATHEXT first, empty string LAST.
|
||||
assert.match(
|
||||
source,
|
||||
/\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/,
|
||||
'extensions array must end with the empty string, not start with it'
|
||||
)
|
||||
// The buggy empty-first order must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\['', \.\.\.\(process\.env\.PATHEXT/,
|
||||
'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe'
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
|
||||
const source = readMain()
|
||||
assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
|
||||
assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal')
|
||||
assert.match(
|
||||
source,
|
||||
/\.hermes-bootstrap-complete/,
|
||||
'recovery must accept the bootstrap-complete marker as a real-install signal'
|
||||
)
|
||||
assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall')
|
||||
// The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/updaterArgs = fileExists\(venvHermes\) \?/,
|
||||
'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'
|
||||
)
|
||||
})
|
||||
|
||||
test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => {
|
||||
const source = readMain()
|
||||
const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(')
|
||||
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts')
|
||||
// Slice out just the function body (up to the next top-level function decl)
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
|
||||
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)
|
||||
assert.match(
|
||||
body,
|
||||
/canImportHermesCli\(python/,
|
||||
'unwrap must probe the venv interpreter; returning it unprobed re-selects a broken venv ' +
|
||||
'forever (Retry/Repair loop on a mid-update venv missing e.g. python-dotenv)'
|
||||
)
|
||||
assert.match(
|
||||
body,
|
||||
/return null\s*\n\s*\}\s*\n\s*return \{/,
|
||||
'a failed probe must fall through (return null) so the resolver reaches the bootstrap rung'
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env'
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function parseRegQueryValue(stdout, name) {
|
||||
if (!stdout || !name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
|
||||
|
||||
for (const rawLine of String(stdout).split(/\r?\n/)) {
|
||||
@@ -70,6 +71,7 @@ function readWindowsUserEnvVar(
|
||||
if (platform !== 'win32' || !name) {
|
||||
return null
|
||||
}
|
||||
|
||||
let stdout
|
||||
|
||||
try {
|
||||
@@ -88,6 +90,7 @@ function readWindowsUserEnvVar(
|
||||
if (raw == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expanded = expandWindowsEnvRefs(raw, env).trim()
|
||||
|
||||
return expanded || null
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { isPackagedInstallPath } from './workspace-cwd'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
decodeClipboardImageBase64,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge'
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
applyZoomLevel,
|
||||
clampZoomLevel,
|
||||
installZoomReassertOnWindowEvents,
|
||||
percentToZoomLevel,
|
||||
ZOOM_REASSERT_WINDOW_EVENTS,
|
||||
ZOOM_STORAGE_KEY,
|
||||
zoomLevelToPercent
|
||||
zoomLevelToPercent,
|
||||
zoomWiringForWindowKind
|
||||
} from './zoom'
|
||||
|
||||
test('storage key stays stable so persisted zoom survives upgrades', () => {
|
||||
@@ -66,12 +66,14 @@ test('extreme percentages clamp to the level bounds', () => {
|
||||
|
||||
test('installZoomReassertOnWindowEvents wires show and restore', () => {
|
||||
const handlers = new Map()
|
||||
|
||||
const win = {
|
||||
isDestroyed: () => false,
|
||||
on(event, listener) {
|
||||
handlers.set(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
installZoomReassertOnWindowEvents(win, () => {
|
||||
calls += 1
|
||||
@@ -86,12 +88,14 @@ test('installZoomReassertOnWindowEvents wires show and restore', () => {
|
||||
test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
|
||||
const handlers = new Map()
|
||||
let destroyed = false
|
||||
|
||||
const win = {
|
||||
isDestroyed: () => destroyed,
|
||||
on(event, listener) {
|
||||
handlers.set(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
installZoomReassertOnWindowEvents(win, () => {
|
||||
calls += 1
|
||||
@@ -101,25 +105,56 @@ test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
|
||||
assert.equal(calls, 0)
|
||||
})
|
||||
|
||||
// Source assertion (see windows-child-process.test.ts for the established
|
||||
// pattern): wireCommonWindowHandlers lives in the electron main entry with heavy
|
||||
// Electron deps, so we assert the wiring contract against source rather than
|
||||
// booting a BrowserWindow. Locks in that the pet overlay opts OUT of global UI
|
||||
// zoom while chat windows keep it — the whole reason this fix is scoped.
|
||||
test('pet overlay opts out of global UI zoom; chat windows keep it', () => {
|
||||
const electronDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(electronDir, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
// The shared helper gates all zoom wiring behind an opt-out flag.
|
||||
assert.match(source, /function wireCommonWindowHandlers\(win, \{ zoom = true \}/)
|
||||
|
||||
// The pet overlay window is the only caller that disables zoom.
|
||||
assert.match(source, /wireCommonWindowHandlers\(win, \{ zoom: false \}\)/)
|
||||
|
||||
// Zoom restore now flows through the shared helper, so createWindow must not
|
||||
// reassert it directly (that would double-fire and drift from session windows).
|
||||
const finishLoad = source.indexOf("mainWindow.webContents.once('did-finish-load'")
|
||||
assert.notEqual(finishLoad, -1, 'missing mainWindow did-finish-load handler')
|
||||
const snippet = source.slice(finishLoad, finishLoad + 300)
|
||||
assert.doesNotMatch(snippet, /restorePersistedZoomLevel\(mainWindow\)/)
|
||||
// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay
|
||||
// opts out. Tested via the extracted config — no source-text regex.
|
||||
test('chat windows opt into zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true })
|
||||
})
|
||||
|
||||
test('pet overlay opts out of zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false })
|
||||
})
|
||||
|
||||
test('unknown window kinds default to chat (zoom enabled)', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })
|
||||
assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })
|
||||
})
|
||||
|
||||
// The UI Scale settings control drifts out of sync after a restart when zoom
|
||||
// is applied to the window but the renderer is never told: its $zoomPercent
|
||||
// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and
|
||||
// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom
|
||||
// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a
|
||||
// level always notifies — the regression can't come back by forgetting a send.
|
||||
function fakeWebContents() {
|
||||
const calls: Array<[string, ...unknown[]]> = []
|
||||
|
||||
return {
|
||||
calls,
|
||||
setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]),
|
||||
send: (channel: string, payload: unknown) => calls.push(['send', channel, payload])
|
||||
}
|
||||
}
|
||||
|
||||
test('applyZoomLevel applies the level then notifies the renderer', () => {
|
||||
const wc = fakeWebContents()
|
||||
const applied = applyZoomLevel(wc, 3)
|
||||
|
||||
assert.equal(applied, 3)
|
||||
assert.deepEqual(wc.calls, [
|
||||
['setZoomLevel', 3],
|
||||
['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }]
|
||||
])
|
||||
})
|
||||
|
||||
test('applyZoomLevel clamps garbage before applying and notifying', () => {
|
||||
const wc = fakeWebContents()
|
||||
const applied = applyZoomLevel(wc, 999)
|
||||
const clamped = clampZoomLevel(999)
|
||||
|
||||
assert.equal(applied, clamped)
|
||||
assert.deepEqual(wc.calls, [
|
||||
['setZoomLevel', clamped],
|
||||
['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -32,6 +32,22 @@ export function percentToZoomLevel(percent) {
|
||||
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a clamped zoom level to a webContents AND notify the renderer, in that
|
||||
* order. Every path that changes zoom (user action, restore-on-load, lifecycle
|
||||
* re-assert) funnels through here so the settings UI Scale control can never
|
||||
* drift from the actually-applied level — the bug where restore set the level
|
||||
* but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%.
|
||||
* Returns the clamped level so callers can persist it.
|
||||
*/
|
||||
export function applyZoomLevel(webContents, level) {
|
||||
const clamped = clampZoomLevel(level)
|
||||
webContents.setZoomLevel(clamped)
|
||||
webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) })
|
||||
|
||||
return clamped
|
||||
}
|
||||
|
||||
// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized
|
||||
// and restored. Re-apply the persisted level on these lifecycle transitions.
|
||||
export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore']
|
||||
@@ -46,7 +62,25 @@ export function installZoomReassertOnWindowEvents(win, reassert) {
|
||||
if (win.isDestroyed?.()) {
|
||||
return
|
||||
}
|
||||
|
||||
reassert()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
|
||||
* global UI zoom; the pet overlay opts out because it sizes its own OS window
|
||||
* to the sprite and inheriting zoom would crop it.
|
||||
*
|
||||
* Extracted so the "pet opts out, everything else opts in" contract is
|
||||
* unit-testable without booting a BrowserWindow or reading source.
|
||||
*/
|
||||
export const ZOOM_WINDOW_CONFIG = {
|
||||
chat: { zoom: true },
|
||||
petOverlay: { zoom: false }
|
||||
} as const
|
||||
|
||||
export function zoomWiringForWindowKind(kind) {
|
||||
return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ export default [
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['*.config.*']
|
||||
files: ['**/*.test.tsx'],
|
||||
rules: {
|
||||
'no-restricted-globals': ['warn', 'document']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -38,14 +38,16 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/wsl-path-bridge.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"test:ui": "vitest run --environment jsdom",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174"
|
||||
"test:ui": "vitest run --project ui",
|
||||
"test:desktop:platforms": "vitest run --project electron",
|
||||
"test": "vitest run",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
|
||||
"check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.12.28",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use strict"
|
||||
|
||||
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
|
||||
//
|
||||
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { checkDistBuilt } from '../scripts/assert-dist-built.mjs'
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
'use strict'
|
||||
/**
|
||||
* before-pack.mjs — electron-builder beforePack hook.
|
||||
*
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs'
|
||||
|
||||
|
||||
@@ -13,12 +13,15 @@ import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve, join } from 'node:path'
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { isMain } from './utils.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -77,9 +80,136 @@ function copyBuildRelease(srcDir, destDir) {
|
||||
}
|
||||
}
|
||||
|
||||
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
|
||||
const srcRoot = resolveNodePtyRoot()
|
||||
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
|
||||
// ─── binary classification ───────────────────────────────────────────
|
||||
//
|
||||
// .node files are shared libraries in the target platform's native binary
|
||||
// format. By reading the first few bytes (magic) we can determine which
|
||||
// platform a given .node was compiled for, without shelling out to `file`.
|
||||
//
|
||||
// ELF (\x7fELF) → linux
|
||||
// Mach-O 32-bit BE (feedface) → darwin
|
||||
// Mach-O 64-bit BE (feedfacf) → darwin
|
||||
// Mach-O 32-bit LE (cefaedfe — CIGAM) → darwin
|
||||
// Mach-O 64-bit LE (cffaedfe — CIGAM_64) → darwin
|
||||
// Fat/Universal BE (cafebabe) → darwin
|
||||
// Fat/Universal LE (bebafeca — FAT_CIGAM) → darwin
|
||||
// PE (MZ DOS header) → win32
|
||||
//
|
||||
// Mach-O and Fat binaries are stored on disk in the host's native byte
|
||||
// order. On x64/arm64 Darwin (every Apple Silicon + every Intel Mac that
|
||||
// ships node-pty prebuilds) that is little-endian, so the on-disk magic is
|
||||
// the CIGAM byte-swapped form, NOT the big-endian MH_MAGIC form. Checking
|
||||
// only the BE constants misclassifies every real Darwin prebuild as unknown.
|
||||
//
|
||||
// Exported for unit testing.
|
||||
|
||||
/**
|
||||
* Classify a native binary's target platform from its magic bytes.
|
||||
* Returns `'linux'`, `'darwin'`, `'win32'`, or `null` if unrecognized
|
||||
* or the file cannot be read.
|
||||
*/
|
||||
export function classifyNativeBinary(filePath) {
|
||||
let buf
|
||||
try {
|
||||
buf = readFileSync(filePath, { start: 0, end: 63 }) // first 64 bytes
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (buf.length < 4) return null
|
||||
|
||||
// ELF: \x7f E L F
|
||||
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
|
||||
return 'linux'
|
||||
}
|
||||
// Mach-O 32-bit (big-endian / MH_MAGIC): feedface
|
||||
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Mach-O 64-bit (big-endian / MH_MAGIC_64): feedfacf
|
||||
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Mach-O 32-bit (little-endian / MH_CIGAM): cefaedfe
|
||||
if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Mach-O 64-bit (little-endian / MH_CIGAM_64): cffaedfe
|
||||
if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Fat/Universal binary (big-endian / FAT_MAGIC): cafebabe
|
||||
if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Fat/Universal binary (little-endian / FAT_CIGAM): bebafeca
|
||||
if (buf[0] === 0xbe && buf[1] === 0xba && buf[2] === 0xfe && buf[3] === 0xca) {
|
||||
return 'darwin'
|
||||
}
|
||||
// PE: MZ DOS header
|
||||
if (buf[0] === 0x4d && buf[1] === 0x5a) {
|
||||
return 'win32'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the staged destination tree for .node files and verify each one's
|
||||
* binary platform matches the requested target. Throws on any mismatch.
|
||||
*
|
||||
* This is the fail-closed safety net: even if a prebuild or build/Release
|
||||
* somehow slipped through with the wrong platform, this catches it before
|
||||
* the package ships a broken native binary to users.
|
||||
*/
|
||||
function validateStagedBinaries(destRoot, targetPlatform) {
|
||||
const mismatches = []
|
||||
function scan(dir, relPrefix) {
|
||||
if (!existsSync(dir)) return
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
scan(join(dir, entry.name), `${relPrefix}${entry.name}/`)
|
||||
continue
|
||||
}
|
||||
if (!entry.name.endsWith('.node')) continue
|
||||
const fullPath = join(dir, entry.name)
|
||||
const classified = classifyNativeBinary(fullPath)
|
||||
if (classified !== targetPlatform) {
|
||||
mismatches.push({ file: `${relPrefix}${entry.name}`, classified, expected: targetPlatform })
|
||||
}
|
||||
}
|
||||
}
|
||||
scan(join(destRoot, 'prebuilds'), 'prebuilds/')
|
||||
scan(join(destRoot, 'build', 'Release'), 'build/Release/')
|
||||
if (mismatches.length > 0) {
|
||||
throw new Error(
|
||||
`[stage-native-deps] native binary platform mismatch (target=${targetPlatform}):\n` +
|
||||
mismatches
|
||||
.map((m) => ` ${m.file}: expected ${m.expected}, got ${m.classified ?? 'unknown'}`)
|
||||
.join('\n') +
|
||||
`\nRefusing to stage a binary compiled for the wrong platform.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage node-pty's native runtime dependencies into `destRoot`.
|
||||
*
|
||||
* Exported separately from `stageNodePty` so tests can supply a fake
|
||||
* node-pty source tree without going through real module resolution.
|
||||
*
|
||||
* Strategy (fail-closed):
|
||||
*
|
||||
* 1. Copy the matching prebuild (`prebuilds/<platform>-<arch>/`) if present.
|
||||
* 2. Copy `build/Release/` **only when the target matches the host** —
|
||||
* build/Release contains a binary compiled for the host's platform/arch,
|
||||
* so staging it for a different target ships a broken app.
|
||||
* 3. If no native binary was staged:
|
||||
* - Same platform as host, different arch → run `electron-rebuild --arch`.
|
||||
* - Different platform from host → throw (cannot cross-compile native
|
||||
* modules; build on the target platform or provide a prebuild).
|
||||
* 4. Validate every staged `.node` file's binary platform matches the target.
|
||||
*/
|
||||
export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platform, arch = process.arch } = {}) {
|
||||
const hostMatch = platform === process.platform && arch === process.arch
|
||||
|
||||
rmSync(destRoot, { recursive: true, force: true })
|
||||
mkdirSync(destRoot, { recursive: true })
|
||||
@@ -91,11 +221,6 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
|
||||
// lib/**/*.js — the JS surface node-pty's `main` points into.
|
||||
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
|
||||
|
||||
// build/Release/* — present when node-pty was compiled locally
|
||||
// (e.g. no prebuild available for this Electron ABI/platform combo).
|
||||
// Some installs won't have this at all if prebuild-install succeeded.
|
||||
copyBuildRelease(join(srcRoot, 'build/Release'), join(destRoot, 'build/Release'))
|
||||
|
||||
// prebuilds/<platform>-<arch>/* — the prebuild-install payload for the
|
||||
// *target* we're packaging, not necessarily the host running this script.
|
||||
// Explicit extensions only, to skip the ~25MB of Windows .pdb symbols
|
||||
@@ -115,21 +240,84 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
|
||||
}
|
||||
if (entry.name === 'spawn-helper') {
|
||||
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
|
||||
chmodSync(join(destPrebuild, entry.name), 0o775)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[stage-native-deps] no prebuild found at prebuilds/${platform}-${arch} for node-pty. ` +
|
||||
`If build/Release/* above is also empty, this target will fail at runtime. ` +
|
||||
`Run "npx electron-rebuild -w node-pty" for this target, or check that ` +
|
||||
`node-pty's published prebuilds cover ${platform}-${arch}.`
|
||||
)
|
||||
}
|
||||
|
||||
// build/Release/* — present when node-pty was compiled locally
|
||||
// (e.g. no prebuild available for this Electron ABI/platform combo).
|
||||
// Only stage this when the target matches the host, because
|
||||
// build/Release contains a binary compiled for the *host's* platform
|
||||
// and architecture. Staging a host binary for a different target (e.g.
|
||||
// a macOS Mach-O .node staged for a linux-arm64 target) ships a broken
|
||||
// app that crashes the first time a terminal is spawned.
|
||||
if (hostMatch) {
|
||||
const buildReleaseDir = join(srcRoot, 'build/Release')
|
||||
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
|
||||
}
|
||||
|
||||
// Check whether a native binary for this target was staged.
|
||||
const stagedDirs = [
|
||||
join(destRoot, 'prebuilds', `${platform}-${arch}`),
|
||||
join(destRoot, 'build/Release')
|
||||
]
|
||||
const hasNativeBinary = stagedDirs.some((dir) => {
|
||||
if (!existsSync(dir)) return false
|
||||
return readdirSync(dir, { recursive: true }).some((name) => String(name).endsWith('.node'))
|
||||
})
|
||||
|
||||
if (!hasNativeBinary) {
|
||||
if (platform !== process.platform) {
|
||||
throw new Error(
|
||||
`[stage-native-deps] no prebuilt binary for ${platform}-${arch} and ` +
|
||||
`cannot cross-compile native modules from ${process.platform}-${process.arch}. ` +
|
||||
`Build on the target platform or provide a prebuild.`
|
||||
)
|
||||
}
|
||||
// Same platform, possibly different arch — rebuild from source with
|
||||
// the target architecture so electron-rebuild produces the correct
|
||||
// binary rather than defaulting to the host's arch.
|
||||
console.log(
|
||||
`[stage-native-deps] no native binary for ${platform}-${arch}; ` +
|
||||
`running electron-rebuild (target arch: ${arch})...`
|
||||
)
|
||||
const rebuildArgs = [
|
||||
'../../node_modules/.bin/electron-rebuild',
|
||||
'-f',
|
||||
'-w',
|
||||
'node-pty',
|
||||
'--arch',
|
||||
arch
|
||||
]
|
||||
const result = spawnSync(process.execPath, rebuildArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`electron-rebuild failed for ${platform}-${arch} (exit ${result.status}). ` +
|
||||
`Cannot stage node-pty without a native binary.`
|
||||
)
|
||||
}
|
||||
// Re-copy build/Release after electron-rebuild populated it.
|
||||
const buildReleaseDir = join(srcRoot, 'build/Release')
|
||||
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
|
||||
}
|
||||
|
||||
// Validate every staged .node binary matches the target platform.
|
||||
validateStagedBinaries(destRoot, platform)
|
||||
|
||||
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)
|
||||
return destRoot
|
||||
}
|
||||
|
||||
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
|
||||
const srcRoot = resolveNodePtyRoot()
|
||||
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
|
||||
return stageNodePtyInto(srcRoot, destRoot, { platform, arch })
|
||||
}
|
||||
|
||||
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
|
||||
if (isMain(import.meta.url)) {
|
||||
const [platform, arch] = process.argv.slice(2)
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs, { existsSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
stageNodePtyInto,
|
||||
classifyNativeBinary
|
||||
} from '../scripts/stage-native-deps.mjs'
|
||||
|
||||
const { join } = path
|
||||
|
||||
// ─── fixtures ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Create minimal fake .node files with correct magic bytes so the
|
||||
// binary classifier and the staging validator exercise real code paths
|
||||
// without needing actual native modules.
|
||||
|
||||
/** Write a fake .node file with the given platform's magic bytes. */
|
||||
function makeFakeNode(filePath, platform) {
|
||||
const headers = {
|
||||
linux: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]), // ELF
|
||||
// On x64/arm64 Darwin, Mach-O binaries are stored little-endian on disk
|
||||
// (MH_CIGAM_64 = cffaedfe). This is the form node-pty's prebuilds ship in.
|
||||
darwin: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00]), // Mach-O 64-bit LE (CIGAM_64)
|
||||
win32: Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // MZ (PE)
|
||||
}
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, headers[platform] ?? headers.linux)
|
||||
}
|
||||
|
||||
/** Create a minimal fake node-pty source tree in a temp dir. */
|
||||
function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) {
|
||||
fs.mkdirSync(srcRoot, { recursive: true })
|
||||
fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'lib/index.js' }))
|
||||
fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true })
|
||||
fs.writeFileSync(join(srcRoot, 'lib', 'index.js'), 'module.exports = {};')
|
||||
|
||||
if (prebuildPlatform && prebuildArch) {
|
||||
const prebuildDir = join(srcRoot, 'prebuilds', `${prebuildPlatform}-${prebuildArch}`)
|
||||
makeFakeNode(join(prebuildDir, 'pty.node'), prebuildPlatform)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── classifyNativeBinary tests ─────────────────────────────────────
|
||||
|
||||
test('classifyNativeBinary detects ELF as linux', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'linux')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Mach-O 64-bit BE as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Mach-O 64-bit LE (CIGAM_64) as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Mach-O 32-bit BE as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Mach-O 32-bit LE (CIGAM) as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xce, 0xfa, 0xed, 0xfe, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Fat/Universal BE (cafebabe) as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Fat/Universal LE (bebafeca / FAT_CIGAM) as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xbe, 0xba, 0xfe, 0xca, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects PE (MZ) as win32', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'win32')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary returns null for unrecognized magic', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), null)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary returns null for a missing file', () => {
|
||||
assert.equal(classifyNativeBinary('/nonexistent/path/to/thing.node'), null)
|
||||
})
|
||||
|
||||
// ─── cross-target regression tests ──────────────────────────────────
|
||||
//
|
||||
// The core bug: stageNodePty receives { platform, arch } from
|
||||
// electron-builder but unconditionally copies host build/Release, staging
|
||||
// a host binary for a foreign target. These tests prove the fix:
|
||||
//
|
||||
// 1. A host build/Release must NOT be staged for a foreign platform.
|
||||
// 2. A matching prebuild IS staged for a foreign target.
|
||||
// 3. A foreign target with no prebuild throws (fail closed).
|
||||
// 4. A host build/Release IS staged for a matching target.
|
||||
// 5. Validation rejects a binary whose magic bytes don't match the target.
|
||||
|
||||
test('cross-target: host build/Release is NOT staged for a foreign platform', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// Create a node-pty tree with ONLY a host build/Release (no prebuild).
|
||||
makeFakeNodePty(srcRoot)
|
||||
const buildReleaseDir = join(srcRoot, 'build', 'Release')
|
||||
makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform)
|
||||
|
||||
// Request a foreign platform (different from the host).
|
||||
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
|
||||
|
||||
assert.throws(
|
||||
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
|
||||
/cannot cross-compile/i
|
||||
)
|
||||
|
||||
// build/Release must NOT have been copied to the dest tree.
|
||||
assert.equal(
|
||||
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
|
||||
false,
|
||||
'host build/Release .node must not be staged for a foreign target'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cross-target: matching prebuild IS staged for a foreign target', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// Host is (say) darwin. Request linux-x64, which has a prebuild.
|
||||
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
|
||||
makeFakeNodePty(srcRoot, { prebuildPlatform: foreignPlatform, prebuildArch: 'x64' })
|
||||
|
||||
// Also create a host build/Release that should NOT be staged.
|
||||
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
|
||||
|
||||
stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' })
|
||||
|
||||
// The foreign prebuild must be staged.
|
||||
const stagedPrebuild = join(destRoot, 'prebuilds', `${foreignPlatform}-x64`, 'pty.node')
|
||||
assert.equal(existsSync(stagedPrebuild), true, 'foreign prebuild must be staged')
|
||||
|
||||
// The host build/Release must NOT be staged.
|
||||
assert.equal(
|
||||
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
|
||||
false,
|
||||
'host build/Release must not be staged for a foreign target'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cross-target: foreign target with no prebuild throws (fail closed)', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// Create a tree with a host build/Release but no foreign prebuild.
|
||||
makeFakeNodePty(srcRoot)
|
||||
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
|
||||
|
||||
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
|
||||
|
||||
assert.throws(
|
||||
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
|
||||
/cannot cross-compile/i
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('host-target: host build/Release IS staged for a matching target', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
makeFakeNodePty(srcRoot)
|
||||
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
|
||||
|
||||
stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch })
|
||||
|
||||
assert.equal(
|
||||
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
|
||||
true,
|
||||
'host build/Release must be staged for a matching target'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('validation rejects a staged binary with the wrong platform magic', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// Create a prebuild dir that claims to be linux-x64 but contains
|
||||
// a darwin (Mach-O) binary. This simulates the original bug where
|
||||
// a host binary ends up in a foreign target's prebuild slot.
|
||||
makeFakeNodePty(srcRoot, { prebuildPlatform: 'linux', prebuildArch: 'x64' })
|
||||
// Overwrite the prebuild .node with the WRONG platform magic.
|
||||
makeFakeNode(join(srcRoot, 'prebuilds', 'linux-x64', 'pty.node'), 'darwin')
|
||||
|
||||
assert.throws(
|
||||
() => stageNodePtyInto(srcRoot, destRoot, { platform: 'linux', arch: 'x64' }),
|
||||
/platform mismatch/i
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -42,7 +42,7 @@ const APP = (() => {
|
||||
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
|
||||
return {
|
||||
appPath: unpacked,
|
||||
binary: path.join(unpacked, 'hermes'),
|
||||
binary: path.join(unpacked, 'Hermes'),
|
||||
resourcesPath: path.join(unpacked, 'resources'),
|
||||
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
||||
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
@@ -108,10 +108,9 @@ function expectedNativeDepPaths() {
|
||||
function ensurePlatformBuilds() {
|
||||
if (PLATFORM === 'darwin') return
|
||||
if (PLATFORM === 'win32') return
|
||||
if (PLATFORM === 'linux') return
|
||||
die(
|
||||
`Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` +
|
||||
`is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` +
|
||||
`install.sh's stage protocol.`
|
||||
`Desktop bundle validation is only wired for darwin / win32 / linux; platform=${PLATFORM} is not supported.`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use strict"
|
||||
|
||||
/**
|
||||
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
|
||||
* .exe should pin to at first-launch bootstrap time. This file ships inside
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
@@ -10,12 +10,17 @@ function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
|
||||
return { id, kind: 'file', label }
|
||||
}
|
||||
|
||||
function renderWithI18n(ui: React.ReactNode) {
|
||||
return render(
|
||||
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
)
|
||||
async function renderWithI18n(ui: React.ReactNode) {
|
||||
let result: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
)
|
||||
})
|
||||
|
||||
return result!
|
||||
}
|
||||
|
||||
describe('AttachmentList', () => {
|
||||
@@ -23,23 +28,22 @@ describe('AttachmentList', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders valid attachments', () => {
|
||||
it('renders valid attachments', async () => {
|
||||
const attachments = [makeAttachment('a', 'doc.pdf'), makeAttachment('b', 'img.png')]
|
||||
renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
await renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
expect(screen.getByText('doc.pdf')).toBeDefined()
|
||||
expect(screen.getByText('img.png')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders empty list without error', () => {
|
||||
renderWithI18n(<AttachmentList attachments={[]} />)
|
||||
it('renders empty list without error', async () => {
|
||||
const { container } = await renderWithI18n(<AttachmentList attachments={[]} />)
|
||||
|
||||
const container =
|
||||
screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
|
||||
const attachmentList = container.querySelector('[data-slot="composer-attachments"]')
|
||||
|
||||
expect(container).toBeDefined()
|
||||
expect(attachmentList).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not crash when attachments array contains undefined entries', () => {
|
||||
it('does not crash when attachments array contains undefined entries', async () => {
|
||||
// Repro: session switch can leave stale/undefined entries in the
|
||||
// attachments array, causing a TypeError at attachment.refText.
|
||||
const attachments = [
|
||||
@@ -48,21 +52,17 @@ describe('AttachmentList', () => {
|
||||
makeAttachment('b', 'also-good.png')
|
||||
]
|
||||
|
||||
expect(() => {
|
||||
renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
}).not.toThrow()
|
||||
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
|
||||
|
||||
// Only valid attachments should render
|
||||
expect(screen.getByText('good.pdf')).toBeDefined()
|
||||
expect(screen.getByText('also-good.png')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not crash when attachments array contains null entries', () => {
|
||||
it('does not crash when attachments array contains null entries', async () => {
|
||||
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
|
||||
|
||||
expect(() => {
|
||||
renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
}).not.toThrow()
|
||||
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
|
||||
|
||||
expect(screen.getByText('valid.txt')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -54,6 +54,10 @@ function Harness({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
@@ -68,16 +72,6 @@ function Harness({
|
||||
const text = draftRef.current
|
||||
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
// Gateway down: queue the draft instead of dropping it (mirrors the real
|
||||
// queueCurrentDraft, which no-ops on an empty payload).
|
||||
if (payloadPresent) {
|
||||
onQueue(text)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
if (payloadPresent) {
|
||||
onQueue(text)
|
||||
@@ -99,10 +93,6 @@ function Harness({
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
if (hasLivePayload) {
|
||||
submitDraft()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,17 +207,16 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues a reconnect draft on Enter (not send/drain) so it flushes when the gateway returns', async () => {
|
||||
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
const onQueue = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness
|
||||
disabled
|
||||
onCancel={vi.fn()}
|
||||
onDrain={onDrain}
|
||||
onQueue={onQueue}
|
||||
onQueue={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
queued={['queued-1']}
|
||||
/>
|
||||
@@ -241,32 +230,8 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
// The gateway is down, so the message can't send — but it must NOT be
|
||||
// silently dropped (the #-bug: "type, hit Enter, nothing happens, no
|
||||
// error"). It queues, and the gateway-open-gated auto-drain sends it later.
|
||||
expect(onQueue).toHaveBeenCalledWith('draft while reconnecting')
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(editor.textContent).toBe('draft while reconnecting')
|
||||
expect(onDrain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an empty Enter while reconnecting as a no-op (no phantom queue entry)', async () => {
|
||||
const onQueue = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={onQueue} onSubmit={onSubmit} />
|
||||
)
|
||||
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = ''
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onQueue).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onDrain).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ interface UseComposerQueueArgs {
|
||||
clearDraft: () => void
|
||||
draftRef: RefObject<string>
|
||||
focusInput: () => void
|
||||
gatewayConnected: boolean
|
||||
loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void
|
||||
onCancel: ChatBarProps['onCancel']
|
||||
onSubmit: ChatBarProps['onSubmit']
|
||||
@@ -53,7 +52,6 @@ export function useComposerQueue({
|
||||
clearDraft,
|
||||
draftRef,
|
||||
focusInput,
|
||||
gatewayConnected,
|
||||
loadIntoComposer,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
@@ -260,7 +258,7 @@ export function useComposerQueue({
|
||||
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
|
||||
// drain lock serializes sends; a remount/reconnect resets the failure counts.
|
||||
const autoDrainNext = useCallback(() => {
|
||||
if (busy || !gatewayConnected || drainingQueueRef.current || !activeQueueSessionKey) {
|
||||
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,7 +289,7 @@ export function useComposerQueue({
|
||||
}
|
||||
})
|
||||
.catch(onFail)
|
||||
}, [activeQueueSessionKey, busy, gatewayConnected, pickDrainHead, queuedPrompts, runDrain, t])
|
||||
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
|
||||
|
||||
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
|
||||
// never churns, so a change there is a real session switch and must NOT
|
||||
@@ -308,15 +306,14 @@ export function useComposerQueue({
|
||||
migrateQueuedPrompts(prev, activeQueueSessionKey)
|
||||
}, [activeQueueSessionKey, queueSessionKey])
|
||||
|
||||
// Queued turns flow whenever the session is idle AND the gateway is open — on
|
||||
// the busy→false settle edge, on mount/reconnect, on the socket reopening, and
|
||||
// after a re-key — so a swallowed edge can't strand them. To cancel queued
|
||||
// turns, the user deletes them from the panel.
|
||||
// Queued turns flow whenever the session is idle — on the busy→false settle
|
||||
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
|
||||
// strand them. To cancel queued turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
if (shouldAutoDrain({ isBusy: busy, isConnected: gatewayConnected, queueLength: queuedPrompts.length })) {
|
||||
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
|
||||
autoDrainNext()
|
||||
}
|
||||
}, [autoDrainNext, busy, gatewayConnected, queuedPrompts.length])
|
||||
}, [autoDrainNext, busy, queuedPrompts.length])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
|
||||
@@ -108,6 +108,10 @@ export function useComposerSubmit({
|
||||
)
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
// render, so on fast typing or IME composition the final keystroke(s) may
|
||||
@@ -127,17 +131,6 @@ export function useComposerSubmit({
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway isn't open (a post-boot reconnect keeps the composer editable by
|
||||
// design). Don't silently drop the Enter — queue the draft so it shows as
|
||||
// pending and the bounded auto-drain flushes it the instant the socket
|
||||
// reopens, instead of the message vanishing with no feedback.
|
||||
if (disabled) {
|
||||
queueCurrentDraft()
|
||||
focusInput()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const text = draftRef.current
|
||||
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ export function ChatBar({
|
||||
|
||||
const { t } = useI18n()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewayConnected = gatewayState === 'open'
|
||||
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
|
||||
const inputDisabled = disabled && !reconnecting
|
||||
|
||||
@@ -179,7 +178,6 @@ export function ChatBar({
|
||||
clearDraft,
|
||||
draftRef,
|
||||
focusInput,
|
||||
gatewayConnected,
|
||||
loadIntoComposer,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
@@ -569,14 +567,7 @@ export function ChatBar({
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
// Gateway down (a post-boot reconnect keeps the composer editable): don't
|
||||
// silently swallow the Enter. Route a real draft into submitDraft, which
|
||||
// queues it so the auto-drain flushes it the instant the socket reopens.
|
||||
if (disabled) {
|
||||
if (hasLivePayload) {
|
||||
submitDraft()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('PreviewPane console state', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not watch backend-only remote filesystem previews locally', () => {
|
||||
it('does not watch backend-only remote filesystem previews locally', async () => {
|
||||
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
|
||||
const onPreviewFileChanged = vi.fn(() => vi.fn())
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
@@ -31,38 +31,43 @@ describe('PreviewPane console state', () => {
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={vi.fn()}
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'file.txt',
|
||||
path: '/remote/file.txt',
|
||||
previewKind: 'text',
|
||||
source: '/remote/file.txt',
|
||||
url: 'file:///remote/file.txt'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
await act(async () => {
|
||||
render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={vi.fn()}
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'file.txt',
|
||||
path: '/remote/file.txt',
|
||||
previewKind: 'text',
|
||||
source: '/remote/file.txt',
|
||||
url: 'file:///remote/file.txt'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(watchPreviewFile).not.toHaveBeenCalled()
|
||||
expect(onPreviewFileChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', () => {
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', async () => {
|
||||
const setTitlebarToolGroup = vi.fn()
|
||||
|
||||
const rendered = render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={{
|
||||
kind: 'url',
|
||||
label: 'Preview',
|
||||
source: 'http://localhost:5174',
|
||||
url: 'http://localhost:5174'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
let rendered!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
rendered = render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={{
|
||||
kind: 'url',
|
||||
label: 'Preview',
|
||||
source: 'http://localhost:5174',
|
||||
url: 'http://localhost:5174'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const initialCalls = setTitlebarToolGroup.mock.calls.length
|
||||
const webview = rendered.container.querySelector('webview')
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import type { HermesGitBaseBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { listBaseBranches } from '@/store/projects'
|
||||
|
||||
// Filterable combobox for picking the base branch of a new worktree. Lists
|
||||
// local + remote-tracking branches, defaults to the default branch
|
||||
// (origin/HEAD, or local main/master when no remote). The current session's
|
||||
// branch is sorted to the top so it's one click away. The parent owns the
|
||||
// selected value via `value` / `onValueChange`.
|
||||
export function BaseBranchPicker({
|
||||
disabled,
|
||||
repoPath,
|
||||
onValueChange,
|
||||
value
|
||||
}: {
|
||||
disabled?: boolean
|
||||
repoPath: string
|
||||
onValueChange: (value: string) => void
|
||||
value: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const repoStatus = useStore($repoStatus)
|
||||
const [branches, setBranches] = useState<HermesGitBaseBranch[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const currentBranch = repoStatus?.detached ? null : (repoStatus?.branch ?? null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!repoPath) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const list = await listBaseBranches(repoPath)
|
||||
setBranches(list)
|
||||
|
||||
// Default to the remote default (origin/HEAD). Fall back to the local
|
||||
// default branch (main/master) when no remote exists. The value is
|
||||
// always a concrete branch — never undefined.
|
||||
const defaultBranch = list.find(b => b.isDefault)
|
||||
|
||||
if (defaultBranch) {
|
||||
onValueChange(defaultBranch.name)
|
||||
} else {
|
||||
onValueChange(list[0]?.name ?? '')
|
||||
}
|
||||
} catch {
|
||||
setBranches([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [repoPath, onValueChange])
|
||||
|
||||
// Load on mount so the default branch fills in before the user opens the
|
||||
// popover — otherwise the button reads "branch off " with nothing after it.
|
||||
useEffect(() => {
|
||||
if (branches.length === 0 && !loading) {
|
||||
void load()
|
||||
}
|
||||
}, [branches.length, loading, load])
|
||||
|
||||
// Pin the current session's branch to the top, keep the rest in git's
|
||||
// most-recently-committed order.
|
||||
const sorted = useMemo(() => {
|
||||
if (!currentBranch) {
|
||||
return branches
|
||||
}
|
||||
|
||||
const idx = branches.findIndex(b => b.name === currentBranch)
|
||||
|
||||
if (idx <= 0) {
|
||||
return branches
|
||||
}
|
||||
|
||||
return [branches[idx], ...branches.slice(0, idx), ...branches.slice(idx + 1)]
|
||||
}, [branches, currentBranch])
|
||||
|
||||
// The i18n function returns { before, after } so the branch name can be
|
||||
// wrapped in its own styled (underlined) span — works for any word order.
|
||||
const parts = p.branchOff()
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Popover
|
||||
onOpenChange={next => {
|
||||
if (next && branches.length === 0 && !loading) {
|
||||
void load()
|
||||
}
|
||||
|
||||
setOpen(next)
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="group w-full flex justify-start items-center min-w-0 gap-1.5 hover:no-underline hover:text-muted-foreground"
|
||||
disabled={disabled || loading}
|
||||
size="inline"
|
||||
variant="text"
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="shrink-0">{parts.before}</span>
|
||||
<span className="shrink-0 text-primary underline-offset-4 decoration-current/20 group-hover:underline">
|
||||
{loading ? '...' : value}
|
||||
</span>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.75rem" />
|
||||
<span className="shrink-0">{parts.after}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="z-[140] min-w-(--radix-popover-trigger-width) p-0">
|
||||
<Command filter={(searchValue, search) => (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}>
|
||||
<CommandInput autoFocus placeholder={p.baseBranchPlaceholder} />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{p.baseBranchNone}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sorted.map(branch => (
|
||||
<CommandItem
|
||||
key={branch.name}
|
||||
onSelect={() => {
|
||||
onValueChange(branch.name)
|
||||
setOpen(false)
|
||||
}}
|
||||
value={branch.name}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-1.5">
|
||||
<Codicon
|
||||
className="shrink-0 text-(--ui-text-tertiary)"
|
||||
name={branch.isRemote ? 'repo' : 'git-branch'}
|
||||
size="0.8rem"
|
||||
/>
|
||||
{branch.isDefault && (
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">★</span>
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
{value === branch.name && (
|
||||
<Codicon className="ml-auto shrink-0 text-(--ui-accent)" name="check" size="0.8rem" />
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchIn
|
||||
|
||||
import { SidebarCount, SidebarRowLead } from '../chrome'
|
||||
|
||||
import { BaseBranchPicker } from './base-branch-picker'
|
||||
|
||||
// Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`),
|
||||
// so plain end-truncation (`truncate`) hides exactly the suffix that tells two
|
||||
// lanes apart — both render as "bb/coding-context…". Keep the tail pinned and
|
||||
@@ -142,6 +144,8 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov
|
||||
// "New worktree": prompt for a branch name, then git spins up a fresh worktree
|
||||
// for that branch under the repo (the lightest way) and we open a new session
|
||||
// inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees.
|
||||
// The base branch defaults to the remote default (origin/HEAD); the user can
|
||||
// pick any local or remote-tracking branch via a filterable combobox.
|
||||
export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
@@ -152,6 +156,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
||||
const [convertMode, setConvertMode] = useState(false)
|
||||
const [branches, setBranches] = useState<HermesGitBranch[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
const [selectedBase, setSelectedBase] = useState('')
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!repoPath) {
|
||||
@@ -181,7 +186,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
||||
try {
|
||||
// Pass the typed value as both the dir slug source and the branch, so the
|
||||
// branch is exactly what the user named (the dir is slugified git-side).
|
||||
const result = await startWorkInRepo(repoPath, { branch, name: branch })
|
||||
const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch })
|
||||
|
||||
if (result) {
|
||||
onStarted(result.path)
|
||||
@@ -238,6 +243,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
||||
onClick={() => {
|
||||
setConvertMode(false)
|
||||
setName('')
|
||||
setSelectedBase('')
|
||||
setOpen(true)
|
||||
}}
|
||||
type="button"
|
||||
@@ -278,22 +284,30 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
||||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={name}
|
||||
/>
|
||||
<>
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={name}
|
||||
/>
|
||||
<BaseBranchPicker
|
||||
disabled={pending}
|
||||
onValueChange={setSelectedBase}
|
||||
repoPath={repoPath}
|
||||
value={selectedBase}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{convertMode ? (
|
||||
|
||||
@@ -239,6 +239,7 @@ export function DesktopController() {
|
||||
const {
|
||||
activeSessionIdRef,
|
||||
ensureSessionState,
|
||||
resetViewSync,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
@@ -619,6 +620,7 @@ export function DesktopController() {
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
resetViewSync,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
|
||||
@@ -313,6 +313,7 @@ export function useGatewayBoot({
|
||||
|
||||
applyDesktopBootProgress(payload)
|
||||
})
|
||||
|
||||
void desktop
|
||||
.getBootProgress()
|
||||
.then(snapshot => applyDesktopBootProgress(snapshot))
|
||||
@@ -356,7 +357,8 @@ export function useGatewayBoot({
|
||||
}
|
||||
})
|
||||
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
|
||||
const sourceProfile = normalizeProfileKey($activeGatewayProfile.get())
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }))
|
||||
|
||||
// Wake signals: power resume (macOS/Windows), network coming back, and the
|
||||
// window regaining focus/visibility. Each nudges an immediate reconnect.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user