A configuration backup is not automatically a secure backup. In an AI coding environment, the archive may contain model endpoints, tool permissions, mount definitions, local instructions, memories, scripts, and visual themes. It may also contain credentials, even when its interface says that secrets are excluded.
SolonCode's Profile backup feature is a useful case study because the implementation is small enough to read end to end. It defines a portable ZIP format, separates settings from directory assets, provides a preview step, protects some settings values by default, and adds limits against oversized uploads and excessive decompression. At the same time, it deliberately stops short of encryption, authenticity, transactional restore, and general-purpose secret discovery.
That combination is more interesting than a feature tour. It shows how a migration tool can establish practical boundaries without pretending to be a vault or a disaster-recovery system. This article reads the implementation as an engineering design: what it includes, what it does when restoring, and what operators must still do themselves.
Start with the asset model
The service exposes six selectable backup keys:
settings
skills
agents
commands
memory
skins
The wording “six asset types” is convenient, but the filesystem model is more precise: one configuration item plus five directory-based asset collections. The source of truth is ProfileService.java, especially buildManifest(), exportZip(), and importCommit(). The HTTP allow-list is repeated in ProfileSettingsController.java as VALID_KEYS.
The default manifest marks settings, skills, agents, commands, and memory as checked. Skins are listed but not checked by default. That distinction matters: a user can migrate the visual layer, but it is not silently included in the default selection shown by the manifest/UI path.
The archive layout is intentionally simple:
manifest.json
settings/global.json
assets/skills/**
assets/agents/**
assets/commands/**
assets/memory/**
assets/skins/**
Only selected keys are written. Settings are not copied as an arbitrary byte-for-byte file. Instead, the current AgentSettings object is serialized and filtered to an allow-list of top-level groups. Directory assets are traversed and copied into their corresponding archive prefixes.
This is a good portability boundary. A consumer does not need to understand the whole SolonCode home directory. It can reason about a manifest, one settings fragment, and five known asset roots. It also leaves room for future schema versions without making the first format dependent on every incidental file in a user's home directory.
Schema versioning is a gate, not authenticity
The archive declares schemaVersion: 1. The exporter writes the value into manifest.json; both preview and commit require a manifest and call requireSchemaVersion().
The importer accepts a positive version no greater than the implementation's current version. A missing, non-positive, or higher version is rejected. Rejecting a future version is preferable to silently interpreting an incompatible structure as if it were known.
But schema validation should not be confused with content validation or trust validation. The implementation does not verify that the manifest's item list exactly matches the ZIP entries. It does not use a manifest hash to prove that files were not changed after export. It does not use a signature to authenticate the producer. It does not establish that a package came from a particular person, machine, or release.
That is a useful general lesson for portable formats: version metadata answers “which structure should I parse?” It does not answer “who produced this?” or “has this package been tampered with?” Those are separate protocol properties.
Settings are filtered by top-level group
The settings fragment is limited to these ten top-level groups:
general
permission
loop
defaultModel
models
providers
mountPools
mcpServers
apiServers
lspServers
The filter is implemented by pickSettingsGroups() in ProfileService.java. Unknown or otherwise unlisted top-level content does not become part of the exported settings fragment. This makes the archive more intentional than a raw dump of an object graph.
The restore operation has an important, sometimes surprising, matching rule. importSettingsFile() iterates through the same group list and executes the equivalent of:
current.set(group, fragment.get(group));
When a group is present, the group is replaced as a whole. This is merge-by-group, not merge-by-entry. If the archive contains a models object, the current models group is replaced by the archived models object; it is not merged model by model. If the archive does not contain mcpServers, the current group remains untouched.
This is a coherent design for a user-selected migration tool. It makes selection meaningful and avoids a vague recursive merge policy. It also creates an operational requirement: review the preview carefully before importing a group such as models, providers, mountPools, or lspServers. A group-level replacement can remove entries that exist in the current group but not in the archive.
The service does create a manual recovery point before changing an existing settings file. If the current file exists, it is copied to a sibling named like:
settings.json.bak-YYYYMMDDHHMMSS
The result includes a warning naming that backup. This is valuable, but it is not an automatic rollback mechanism. The backup is for settings.json; it does not snapshot the five asset directories. The new settings content is written with Files.write() inside the profile service rather than through the atomic temporary-file-and-move path used elsewhere by settings persistence.
“Secrets excluded” has a narrow meaning
The default export passes includeSecrets=false. The implementation then recursively walks the settings JSON and masks only six exact, case-sensitive field names:
apiKey
api_key
webAuthPass
webAuthUser
dbPassword
ldapAdminPassword
A value is replaced only when the matching field's value is a string. The replacement marker is:
__MASKED__
This is not a generic password detector. A field called token, secret, password, accessToken, or clientSecret is not automatically recognized. A differently capitalized field is not recognized. A non-string value is not replaced by this branch. The safe wording is therefore “six exact settings field names are masked,” not “all secrets are removed.”
The masking routine does recurse through objects and arrays, which means a matching name can be found below the top level. That improves coverage for nested model or server configuration. It does not change the name-based nature of the policy.
There is a second boundary that is even more important: assets are not scanned. addAssetDir() copies files from skills, agents, commands, memory, and skins directly into the ZIP. It does not call maskSecrets(). It does not search for .env files, token patterns, passwords, or the six field names inside text files.
As a result, a default export can still contain a credential embedded in a skill instruction, an agent configuration, a command script, a memory note, or a skin asset. The masked flag attached to the settings manifest item describes the settings fragment; it does not certify that the complete archive is secret-free.
This is a strong example of why security documentation must name the protected surface. “Default secret masking” sounds broad until the implementation is read. “Default masking of six exact string field names in settings JSON; directory assets are copied without scanning” is less promotional and much more useful.
Explicit plaintext export is a deliberate risk switch
includeSecrets is exposed by the export controller and defaults to false. If an operator explicitly sets it to true, the settings fragment retains the matching values in plaintext. The manifest records that choice, but the import path does not turn it into an additional trust or cryptographic policy.
There is no ZIP encryption, no password-based key derivation, and no secret-specific encryption layer. Therefore an includeSecrets=true archive should be treated as a credential bundle. It should not be committed to a source repository, attached to a public issue, placed in a shared chat, or stored on an untrusted file share.
Even a default masked archive deserves review because the five directory asset types are not scanned. If the intended migration includes secrets, a better operational pattern is to keep the archive non-secret and provision credentials separately through the destination environment. If plaintext export is unavoidable, restrict access, shorten retention, and rotate exposed credentials when the transfer is complete.
Preview and commit are separate uploads
The web controller exposes two POST endpoints:
/web/settings/profile/import/parse
/web/settings/profile/import/commit
Both require a multipart file. Both read the complete stream into memory. Both independently invoke the profile service. The preview endpoint unpacks into a temporary directory, checks the manifest and schema, calculates settings group information, and counts new versus overwritable asset files. It does not write the target settings or asset directories.
The commit endpoint receives another upload, creates another temporary directory, unpacks again, validates again, then applies the selected settings and assets.
This means the preview result is not a server-side handle to an immutable package. There is no persisted review token, no archive hash comparison, and no server-held staging directory reused by commit. A user can preview archive A and submit archive B. The selected keys can also differ between the requests.
The two-step UI is still useful as a human workflow, but its protocol meaning must be stated accurately: preview is an inspection operation, not a cryptographic binding between the reviewed bytes and the committed bytes. If “what I reviewed is exactly what I commit” is a requirement, the protocol needs an archive digest or a server-side staged object with an expiring commit token.
Resource limits help, but they are not a complete ZIP policy
The controller limits each uploaded compressed stream to:
64 * 1024 * 1024
That is 64 MiB. The check is in ProfileSettingsController.readAll() and applies independently to the parse and commit uploads. The exporter itself builds a complete ZIP in memory and does not apply this same output limit.
During extraction, ProfileService.unzipSafely() counts bytes actually written across entries. If the cumulative uncompressed output exceeds:
256 * 1024 * 1024
that is 256 MiB, extraction stops with an error. This provides a useful defense against an archive whose compressed size is small but whose expanded output is large.
There are important omissions. The code does not enforce a maximum number of entries. It does not enforce a compression-ratio limit. It does not enforce a real per-file uncompressed threshold. A local variable named written is incremented, and a comment refers to limiting one file, but no comparison against a per-file limit exists. The actual implemented byte defense is cumulative, not per-entry.
The importer also skips, rather than rejects, entries whose names contain .., and applies a normalized path containment check. This provides basic Zip Slip protection for the temporary extraction directory. It is not a complete malicious archive policy. There is no duplicate-entry uniqueness check, no signature verification, and no complete trust decision for packages from unknown sources.
Both parse and commit attempt recursive cleanup in a finally block. The temporary directory prefixes are different: soloncode-profile-unpack- for preview and soloncode-profile-import- for commit. This is good lifecycle hygiene, but cleanup can itself encounter I/O errors, and the existing service tests do not assert that successful and failing paths leave no temporary directories behind.
Asset restore is additive and overwriting, not mirroring
For each selected asset directory, importAssetDir() walks the extracted source and copies every file to the target with REPLACE_EXISTING.
The resulting behavior is:
- a missing destination file is added;
- a same-path destination file is overwritten;
- a destination file absent from the archive is retained.
The third rule is easy to miss. The importer does not delete the target directory and does not synchronize it to an exact snapshot. This reduces destructive surprises, especially when a user imports only one collection. It also means migration may leave obsolete skills, commands, memories, or skin files in place.
The distinction between “restore a portable selection” and “replace the complete local state” is a general design choice worth making explicit. The former is safer for ordinary users. The latter would require a much stronger review and deletion model.
The asset operation is also not backed by an asset-level pre-import snapshot. If a file is overwritten and a later operation fails, the profile service does not automatically restore the old asset.
Commit is not transactional
The commit sequence is ordered: unpack and validate, apply settings if selected, then process skills, agents, commands, memory, and skins. There is no transaction spanning these filesystem operations, no staging tree swapped into place as one unit, and no compensating rollback.
Suppose settings has already been written and several skill files have been copied when an agents file fails. The previous changes remain. The settings backup may allow manual restoration of the old settings file, but it does not restore overwritten assets and does not reverse the whole operation.
This is not necessarily a defect for a compact local utility. Transactional filesystem migration is complex, especially when multiple directories and user-created files are involved. It is, however, a boundary that must appear in runbooks. Treat commit as a sequence of applied writes, not as an all-or-nothing change.
A cautious migration procedure is therefore straightforward:
- Make an independent backup of the relevant SolonCode home data.
- Inspect the archive source and contents before uploading it.
- Preview the exact keys that will be submitted.
- Prefer a separate credential provisioning step.
- Commit in a controlled environment, possibly in smaller selections.
- Verify settings and representative files after commit.
- Reload or restart according to the changed configuration.
Reload is not the same as restart
The profile commit itself does not call reload. The controller's response and comments point callers to /web/settings/reload afterward.
WebSettingsController.settingsReload() invokes AgentSettings.reloadInPlace(). The settings class first loads and parses a disk snapshot, fills runtime defaults, compares content, and only then copies values into the current instance. The tests for AgentSettingsReloadTest.java cover useful in-place properties such as retaining final group object identity, replacing maps in place, preserving memory on corrupt input, and layering local settings over global settings.
The reload controller applies or diffs several categories, including general settings, permissions, default model, models, MCP servers, and API servers. But the implementation explicitly warns about two categories:
mountPools changed; memory updated, restart recommended for full runtime effect
lspServers changed; memory updated, restart recommended for full runtime effect
In other words, mountPools and lspServers may be updated in memory while their complete runtime effect still requires a restart. Also, reload with apply=false refreshes memory without applying engine-side changes.
A migration runbook should not say “import, and everything is hot.” It should say “import, reload where appropriate, and restart when the runtime warning requires it.”
What the current tests prove—and do not prove
ProfileServiceTest.java contains 12 tests. They were also run against the reviewed source commit with:
mvn -pl soloncode-cli -am \
-Dtest=ProfileServiceTest \
-Dsurefire.failIfNoSpecifiedTests=false \
test
The run completed with 12 tests, zero failures, zero errors, zero skipped, and Maven BUILD SUCCESS. Reading the assertions shows a focused baseline:
- selected-key export is checked;
- default masking and explicit plaintext export are checked for
apiKey; - basic manifest structure and schema version are checked;
- empty selection is rejected;
- missing manifests and invalid schema cases are rejected;
- commit independently validates schema;
- an existing masked
apiKeyis preserved during settings import; - a deleted skill file can be restored;
- one
../Zip Slip entry is skipped.
This is useful coverage of the central happy paths. It is not complete coverage of the six-key feature or the security boundary.
The tests do not directly cover the controller's 64 MiB upload limit. They do not exercise the 256 MiB cumulative extraction boundary. They do not verify temporary directory cleanup. They do not verify same-name asset overwrite together with retention of extra destination files. They only incidentally execute settings backup logic without asserting the backup file's existence, content, or warning.
They also do not construct a partial failure to demonstrate the non-transactional result. They do not test preview A followed by commit B. Only apiKey is tested among the six exact secret names. There is no asset file containing a secret to demonstrate that assets are copied without scanning. Agents, commands, memory, and skins lack the same file-level positive restore coverage given to skills.
The test suite also cannot cover controls that the implementation does not have: entry-count limits, compression-ratio limits, per-file extraction limits, encryption, signatures, or cryptographic hashes.
The right conclusion is neither “the feature is untested” nor “the feature is fully secure.” The source and tests together show a sensible baseline with clearly identifiable follow-up work.
The engineering lesson
SolonCode's Profile backup design is valuable precisely because its guarantees are bounded. It defines a portable schema v1 ZIP. It distinguishes settings from five asset directories. It filters settings by known top-level groups. It masks six exact string field names by default. It makes plaintext export explicit. It limits compressed uploads to 64 MiB and cumulative extraction to 256 MiB. It performs basic Zip Slip path checks and attempts temporary-directory cleanup. It restores assets by adding and replacing files without deleting extra destination files.
It does not encrypt archives. It does not authenticate their source. It does not hash or sign their contents. It does not scan directory assets for secrets. It does not bind preview bytes to commit bytes. It does not provide entry-count, compression-ratio, or per-file limits. It does not make restore transactional or automatically roll back. It may require a restart for the complete runtime effect of mountPools or lspServers changes.
That is a credible migration utility boundary, not a secret manager boundary. The practical design principle is simple: make the portable unit explicit, make destructive behavior limited, and document every guarantee narrowly enough that operators can build the missing controls around it.
For any tool that moves AI configuration, this is the standard worth aiming for. Security is not the number of safeguards listed in a settings page. It is the precision of the contract between what the code protects, what it leaves untouched, and what the person operating the migration must still verify.
Source paths
The implementation and tests discussed here are in the SolonCode repository:
soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/service/ProfileService.javasoloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/ProfileSettingsController.javasoloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebSettingsController.javasoloncode-cli/src/main/java/org/noear/solon/codecli/config/AgentSettings.javasoloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/service/ProfileServiceTest.javasoloncode-cli/src/test/java/org/noear/solon/codecli/config/AgentSettingsReloadTest.java
The source claims in this article were checked against repository commit 334284123ca6c7069cc58f24e044f8785bd6dc9d. The focused ProfileServiceTest run passed as described above; no performance measurement is being claimed.
Top comments (0)