<inputtype="password"value={apiKeyDraft}placeholder={setupModeDraft==="direct-provider"?"????? API Key":(workspace?.apiKeyConfigured?ui.changeApiKey:ui.apiKeyPlaceholder)}onChange={(event)=>setApiKeyDraft(event.target.value)}/>
<inputtype="password"value={apiKeyDraft}placeholder={setupModeDraft==="direct-provider"?"Enter API Key":(workspace?.apiKeyConfigured?ui.changeApiKey:ui.apiKeyPlaceholder)}onChange={(event)=>setApiKeyDraft(event.target.value)}/>
</label>
</div>
<divclassName="button-row settings-actions">
...
...
@@ -1587,14 +1766,14 @@ export default function App() {
<inputtype="password"value={apiKeyDraft}placeholder={setupModeDraft==="direct-provider"?"????? API Key":ui.apiKeyPlaceholder}onChange={(event)=>setApiKeyDraft(event.target.value)}/>
<inputtype="password"value={apiKeyDraft}placeholder={setupModeDraft==="direct-provider"?"Enter API Key":ui.apiKeyPlaceholder}onChange={(event)=>setApiKeyDraft(event.target.value)}/>
-`vendor/openclaw-runtime` is reserved for the pinned runtime payload
-`installer-smoke.ps1` performs a real silent NSIS install into `.tmp`, launches the installed app in smoke mode, and validates packaged paths plus diagnostics output
-`electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output
-`materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it
-`electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions
-`materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation
-`materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache`
-`bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end
-`workspace-entry-smoke.ps1` materializes the bundled runtime payload, prepares an isolated active project fixture, and validates the workspace-entry execution path end to end as a formal regression smoke; `pnpm smoke:workspace-entry`
-`cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle`
-`default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat`
-`installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`
-`project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh`
-`project-empty-inventory-smoke.ps1` compiles the targeted `project-empty-inventory-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that an empty project inventory stays empty, session listing returns `[]`, session creation is blocked with the pending-cloud message, and the first synced bundle-backed project becomes active cleanly; `pnpm smoke:empty-project-inventory`
-`project-bundle-reconcile-smoke.ps1` compiles the targeted `project-bundle-reconcile-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies stale bundle project removal, shared `skills/` cleanup, shared `cron/` cleanup, manifest pruning, and empty-inventory cleanup without recreating a local fallback project; `pnpm smoke:bundle-reconcile`
-`project-bundle-freshness-smoke.ps1` compiles the targeted `project-bundle-freshness-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that the same bundle URL plus unchanged `configVersion` still re-syncs when remote `ETag` / `Last-Modified` freshness metadata changes; `pnpm smoke:bundle-freshness`
-`project-bundle-replacement-smoke.ps1` compiles the targeted `project-bundle-replacement-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies same-project replacement, shared `skills/` and `cron/` ownership cleanup, rollback on an injected post-commit failure, and successful recovery on the next sync; `pnpm smoke:bundle-replacement`
-`project-isolation-smoke.ps1` runs the project-isolation regression smokes back to back, including the targeted default-chat, project-context refresh, and empty-project-inventory smokes; `pnpm smoke:project-isolation`
assert(firstPreparation.decision.kind==="chat-fallback","Expected the first default chat preparation to route to chat-fallback.");
assert(firstPreparation.sessionStateAfter.contextSnapshotId===firstPreparation.snapshot.snapshotId,"Initial default chat preparation did not bind session.contextSnapshotId.");
assert(shouldRefreshProjectContextAfterExecution(firstPreparation.decision),"chat-fallback should schedule post-execution project context refresh.");
assert(firstPreparation.decision.preparedPrompt.includes(readmeMarkerBefore),"Initial default chat prepared prompt did not include the original README marker.");
assert(firstPreparation.decision.preparedPrompt.includes(`Current project: ${project.name} (${project.id})`),"Initial default chat prepared prompt did not include the project identity.");
assert(firstPreparation.decision.preparedPrompt.includes(`Project root: ${projectRoot}`),"Initial default chat prepared prompt did not include the project root.");
assert(firstPreparation.decision.preparedPrompt.includes("Keep project context isolated to this project and session."),"Initial default chat prepared prompt did not include the isolation instruction.");
assert(staleSessionState.contextSnapshotId===firstPreparation.snapshot.snapshotId,"Session snapshot changed before the post-execution refresh step ran.");
assert(refreshedSnapshot.snapshotId!==firstPreparation.snapshot.snapshotId,"Post-execution default chat refresh did not produce a new snapshotId.");
assert(refreshedSnapshot.readme?.includes(readmeMarkerAfter),"Post-execution default chat refresh did not include the updated README marker.");
assert(reboundSessionState.contextSnapshotId===refreshedSnapshot.snapshotId,"Post-execution default chat refresh did not rebind session.contextSnapshotId.");
constsecondPreparation=awaitprepare(promptAfter);
assert(secondPreparation.decision.kind==="chat-fallback","Expected the second default chat preparation to route to chat-fallback.");
assert(secondPreparation.snapshot.snapshotId===refreshedSnapshot.snapshotId,"Second default chat preparation did not reuse the refreshed snapshotId.");
assert(secondPreparation.sessionStateAfter.contextSnapshotId===secondPreparation.snapshot.snapshotId,"Second default chat preparation did not preserve the rebound session.contextSnapshotId.");
assert(secondPreparation.decision.preparedPrompt.includes(readmeMarkerAfter),"Second default chat prepared prompt did not include the updated README marker.");
assert(secondPreparation.decision.preparedPrompt.includes(promptAfter),"Second default chat prepared prompt did not preserve the user prompt.");
if (!statusLabels.some((label) => label.toLowerCase().includes('workspace'))) {
throw new Error('Workspace-entry smoke did not report a workspace status label. history=' + JSON.stringify(statusLabels) + ' latest=' + latestStatusLabel);
}
if (!assistantContent.includes('desktop project-isolated workspace')) {
throw new Error('Workspace-entry smoke did not echo the injected isolated workspace context.');
throw new Error('Workspace-entry smoke did not reference the expected project identity.');
}
if (!assistantContent.includes('Project root: ' + expectedProjectRoot)) {
throw new Error('Workspace-entry smoke did not reference the expected project root.');
}
if (!assistantContent.includes('Keep project context isolated to this project and session.')) {
throw new Error('Workspace-entry smoke did not preserve the project isolation instruction.');
}
if (selectedSkillId) {
throw new Error('Workspace-entry smoke unexpectedly selected a skill: ' + selectedSkillId);
}
if (!String(sendResult.sessionId || streamSmoke.sessionId || '').startsWith(expectedSessionPrefix)) {
throw new Error('Workspace-entry smoke did not bind the session to the expected project: ' + String(sendResult.sessionId || streamSmoke.sessionId || ''));
}
if (Number(streamSmoke.deltaEventCount || 0) < 1) {
throw new Error('Workspace-entry smoke did not emit a delta event.');
}
if (Number(sendResult.messageCount || 0) < 2) {
throw new Error('Workspace-entry smoke did not persist the expected user/assistant message pair.');
assert(firstRecord,"First bundle sync did not persist a manifest record.");
assert(firstRecord.remoteEtag==='"bundle-freshness-a"',`Unexpected first remoteEtag: ${String(firstRecord.remoteEtag||"")}`);
assert(firstRecord.remoteLastModified==="Tue, 31 Mar 2026 13:00:00 GMT",`Unexpected first remoteLastModified: ${String(firstRecord.remoteLastModified||"")}`);
assert(firstRecord.remoteContentLength===4096,`Unexpected first remoteContentLength: ${String(firstRecord.remoteContentLength??"")}`);
assert(recordAfterB,"Variant B sync did not persist a manifest record.");
assert(recordAfterB.remoteEtag==='"bundle-replacement-b"',`Unexpected variant B remoteEtag: ${String(recordAfterB.remoteEtag||"")}`);
assert((awaitreadFile(readmePath,"utf8")).includes("Replacement variant B"),"Variant B was not materialized into the project README.");
assert(awaitpathExists(path.join(workspaceRoot,"skills",variantSkillEntry.b)),"Variant B skill entry was not materialized.");
assert(awaitpathExists(path.join(workspaceRoot,"cron",variantCronEntry.b)),"Variant B cron entry was not materialized.");
assert(!(awaitpathExists(path.join(workspaceRoot,"skills",variantSkillEntry.a))),"Variant A skill entry was not cleaned up after replacement with variant B.");
assert(!(awaitpathExists(path.join(workspaceRoot,"cron",variantCronEntry.a))),"Variant A cron entry was not cleaned up after replacement with variant B.");
assert(replacementFailure==="Simulated syncBundleProject failure after bundle replacement commit.","Injected replacement failure did not surface the expected error.");
assert(readmeAfterFailure.includes("Replacement variant B"),"Rollback did not restore variant B README after injected failure.");
assert(!readmeAfterFailure.includes("Replacement variant C"),"Rollback left variant C README content in place after injected failure.");
assert(awaitpathExists(path.join(workspaceRoot,"skills",variantSkillEntry.b)),"Rollback did not restore variant B skill entry after injected failure.");
assert(!(awaitpathExists(path.join(workspaceRoot,"skills",variantSkillEntry.c))),"Rollback left variant C skill entry in place after injected failure.");
assert(awaitpathExists(path.join(workspaceRoot,"cron",variantCronEntry.b)),"Rollback did not restore variant B cron entry after injected failure.");
assert(!(awaitpathExists(path.join(workspaceRoot,"cron",variantCronEntry.c))),"Rollback left variant C cron entry in place after injected failure.");
assert(createdSession.projectId===syncedProject.id,"Created session after bundle sync should be bound to the active project.");
assert(listedSessionsAfterSync.some((session)=>session.id===createdSession.id),"Created session after bundle sync should appear in the active session list.");
Status: Bundle ingestion, cloud-owned inventory enforcement, freshness hardening, replacement/rollback hardening, and lifecycle smoke are implemented
## 1. Product constraints
This plan follows the confirmed product constraints:
- Projects are defined by backend config, not local CRUD.
- The current source payload is `openclaw-employee-config`.
- The active bundle source is `skills[].skill.download_url`.
-`project_bundle_url` is not part of the current implementation.
- Project inventory is fixed by backend configuration.
## 2. What is already implemented
### Cloud bundle ingestion
- Runtime cloud bootstrap and payload update both trigger project bundle sync.
- Zip-backed remote skills are discovered and downloaded.
- Bundles are extracted and normalized.
- Project files are materialized into local project roots.
- Shared bundle `skills/` and `cron/` content is materialized.
- Synced projects are registered in local project metadata.
### Cloud-owned inventory enforcement
- No implicit local `default` project is created when there are no materialized projects.
- Session listing returns `[]` when no active project exists.
- Session creation is blocked with `Waiting for cloud project bundle sync.` until a cloud-backed project arrives.
- The first synced bundle-backed project becomes active cleanly.
### Project-bound execution
- Sessions are bound to projects.
- Each execution is prepared against the session's project.
- Project context snapshots are injected into request preparation.
- The router can choose `workspace-entry`, `skill`, or `chat-fallback`.
-`chat-fallback`, `skill`, and `workspace-entry` all schedule post-execution project context refresh.
### Stale bundle reconciliation
- Sync derives the expected bundle-backed project set from the current payload.
- Bundle-managed projects missing from the expected set are removed locally.
- Bundle-managed shared `skills/` entries missing from the expected set are removed locally.
- Bundle-managed shared `cron/` entries missing from the expected set are removed locally.
- Stale bundle manifest records are pruned.
- If the active bundle project is removed, active project falls back to the remaining valid project set.
### Freshness hardening
- Reuse is no longer assumed from `sourceUrl + configVersion` alone.
- When a manifest record is otherwise reusable, the bundle service performs a `HEAD` probe.
- The probe captures `ETag`, `Last-Modified`, and `Content-Length` when available.
- If those values change, the bundle is re-downloaded and re-materialized.
- If an older manifest lacks freshness identity and the remote now exposes one, the service forces one refresh to establish the baseline.
- Bundle manifest records now persist remote freshness metadata.
### Replacement and rollback hardening
- Bundle installation now follows `stage -> commit -> finalize/rollback`.
- Replacement can swap the project root plus shared `skills/` and `cron/` entries for the same logical `projectId`.
- If replacement fails after commit but before project metadata is fully updated, the previous project root, shared skill entries, and cron entries are restored.
- Recovery on the next sync is verified and keeps manifest ownership aligned with the recovered state.
### Regression smoke coverage
-`workspace-entry-smoke`
-`cloud-bundle-smoke`
-`project-context-refresh-smoke`
-`default-chat-smoke`
-`project-empty-inventory-smoke`
-`project-bundle-reconcile-smoke`
-`project-bundle-freshness-smoke`
-`project-bundle-replacement-smoke`
-`project-isolation-smoke`
Additional lifecycle coverage now verified:
- cloud bundle smoke covers payload `sync`
- cloud bundle smoke covers cached `init`
- cloud bundle smoke covers same-`projectId` replacement through Electron UI/main flow
- replacement smoke covers service-level rollback injection and recovery on the next sync
## 3. What is no longer pending
The previously pending items are now implemented:
- full cloud-owned project inventory enforcement
- strong rollback guarantees for partially failed bundle replacement
- end-to-end lifecycle smoke for replacement scenarios
## 4. Remaining follow-up work
The main work left is follow-up hardening rather than missing core behavior:
- broaden replacement coverage beyond the single-project happy path to larger multi-project churn scenarios
- decide whether replacement lifecycle smoke should also be aggregated into higher-level CI or release gates
- keep the design docs aligned as the project-isolation surface expands
2. Remote zip bundles are discovered from `skills[].skill.download_url`, downloaded, and extracted.
3. Bundle content is materialized into local project roots plus shared `skills/` and `cron/` content.
4. Materialized projects are registered in local project metadata.
5. Empty inventory does not recreate a local fallback project; chat session creation waits for the first cloud-backed project.
6. Sessions are bound to projects through `ProjectStoreService`.
7.`ProjectContextService` builds and caches project snapshots from `SOUL.md`, `USER.md`, `README.md`, and tracked memory files.
8.`ProjectExecutionRouter` routes each request to one of:
-`workspace-entry`
-`skill`
-`chat-fallback`
9.`ipc.ts` prepares each request against the session's project snapshot before execution.
10. Bundle reconciliation removes stale bundle-managed projects, shared `skills/` entries, shared `cron/` entries, and stale manifest records when the expected cloud bundle set changes.
11. Bundle freshness probes the remote bundle with HTTP metadata before deciding whether an existing local materialization can be reused.
12. Same-`projectId` replacement now stages project/shared assets, commits them in order, and either finalizes or rolls back the whole replacement set.
## 3. Code-truth execution behavior
Current behavior verified from code:
-`workspace-entry` executes inside the isolated project root.
Status: Foundation, cloud-owned inventory, freshness hardening, replacement/rollback hardening, and lifecycle smoke are implemented
## 1. Intent
The goal is a single desktop app instance with isolated project execution.
Isolation means:
- each session is bound to a project
- prompts are prepared with that project's context only
- execution routing stays inside the selected project's boundaries
- project state should not leak across sessions or projects
## 2. What is implemented
### Project and session model
- Projects exist in local workspace state.
- Sessions are created per project.
- Active project selection is real.
- Session to project binding is real.
### Cloud-owned inventory behavior
- No project means no implicit local `default` project.
- Session listing returns `[]` when inventory is empty.
- Creating a session while waiting for the first cloud project returns `Waiting for cloud project bundle sync.`.
- The first synced bundle-backed project becomes active without local fallback recreation.
### Project context model
-`ProjectContextService` builds snapshots from project root files.
-`SOUL.md`, `USER.md`, `README.md`, and tracked memory files are included.
- Snapshot caching, invalidation, and refresh are implemented.
-`session.contextSnapshotId` rebinding is implemented.
### Execution routing
The router can choose:
-`workspace-entry`
-`skill`
-`chat-fallback`
All three routes are project-aware.
### Cloud bundle path
- Cloud payload can define zip-backed projects.
- Bundles are downloaded and materialized locally.
- Shared bundle assets can also be materialized.
- Stale bundle-managed projects and shared assets can be removed when the expected cloud bundle set changes.
- Same-URL bundle changes can now trigger re-download through HTTP freshness metadata.
- Same-`projectId` bundle replacement now uses explicit stage/commit/finalize-or-rollback handling.
- If replacement fails after commit but before metadata sync completes, the previous project root and shared assets are restored.
## 3. Current default chat behavior
Default chat is no longer a special weak path.
Current behavior:
- it reads the latest available project snapshot before each request
- it injects project context into prepared prompt content
- after a `chat-fallback` turn completes, it queues project context refresh in the background
- when refresh succeeds, `session.contextSnapshotId` is rebound to the refreshed snapshot
- the next request reuses the refreshed snapshot
The refresh remains asynchronous so reply delivery is not blocked.
## 4. Verified coverage
Current repo smoke coverage includes:
-`workspace-entry-smoke.ps1`
-`cloud-bundle-smoke.ps1`
-`project-context-refresh-smoke.ps1`
-`default-chat-smoke.ps1`
-`project-empty-inventory-smoke.ps1`
-`project-bundle-reconcile-smoke.ps1`
-`project-bundle-freshness-smoke.ps1`
-`project-bundle-replacement-smoke.ps1`
-`project-isolation-smoke.ps1`
Additional verified points:
- cloud bundle smoke passes with freshness probing enabled
- cloud bundle smoke now validates same-`projectId` replacement through the Electron UI/main chain
- service-level replacement smoke validates rollback injection and recovery on the next sync
- Electron smoke validates workspace-agent status history instead of relying on the final status label only
## 5. Important current limitations
### Broad UI regression breadth is still selective
The targeted Electron lifecycle smoke for cloud bundle replacement now exists, but the full UI regression matrix is still intentionally selective rather than exhaustive.
### Follow-up hardening can still expand
The current replacement lifecycle coverage is strong for the implemented path, but future changes may still need wider multi-project churn and stress coverage.
## 6. What has been completed so far
Completed enough to count as real implementation:
- project-bound session model
- cloud-owned inventory enforcement
- project context snapshot model
- cloud zip materialization path
- project-aware execution routing
- default chat refresh alignment with other project-aware paths
- stale bundle-managed cleanup for project/skill/cron/manifest state
- bundle freshness hardening using remote metadata probe
- replacement / rollback hardening for same-project bundle updates
- smoke coverage for empty inventory, removal, freshness, replacement, and Electron lifecycle validation
This project is no longer at the design-only stage.
## 7. What should happen next
Recommended next work:
1. Keep the current smoke set green as adjacent runtime work lands.
2. Fold the new lifecycle replacement smoke into any broader release gate if needed.
3. Expand coverage only when upcoming product changes introduce new isolation surfaces.
## 8. Final summary
The single-instance plus task-isolation foundation is implemented.
The earlier main gaps around cloud-owned inventory, replacement/rollback hardening, and lifecycle smoke have been closed.
The remaining work is mostly broader regression breadth and routine maintenance.