Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions ai-assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,40 @@ The build resolves `plugin-api.jar` from the repo-root `../libs/`.

## Security

- **Gemini API key at rest.** When you use the Gemini backend, the API key is
stored in this plugin's private `SharedPreferences` (`AgentSettings`). That
store is **app-sandboxed** — other apps cannot read it — but it is **not
encrypted at rest**, so it is recoverable on a rooted or otherwise compromised
device. Remove it any time from **AI Settings** (or `clearGeminiApiKey()`).
Encryption is deliberately not layered on here: the key is shared at runtime
with the sibling `ai-core` plugin (which performs the Gemini calls), and
`EncryptedSharedPreferences` would force both independent plugins to agree on a
master-key alias and crypto library version — a fragile cross-plugin coupling.
A host-provided secure-storage service is the correct long-term fix.
- **Gemini API key at rest.** The key is encrypted with AES/GCM under a
hardware-backed Android Keystore secret and only the ciphertext is written to
this plugin's private `SharedPreferences` (`AgentSettings`), as
`enc:v1:` + base64(iv‖ciphertext). A copied prefs file — root, `adb backup`,
forensic dump — is useless without this device's Keystore. Remove the key any
time from **AI Settings** (or `clearGeminiApiKey()`).
- A key stored before this plugin encrypted them is still plaintext on disk;
it is re-encrypted in place the first time it's read
(`SecureApiKeyStore.readAndMigrate`), so no user action is needed.
- The key is **not** bound to user authentication
(`setUserAuthenticationRequired` is not set), so it stays usable for
background inference and a lock-screen credential change does not invalidate
it. If the Keystore entry is lost anyway — the app's data is cleared, or an
OEM Keystore drops the alias — the stored key can no longer be decrypted and
must be re-entered; **Edit** says so instead of presenting an empty field.
Saving surfaces a failure message rather than silently storing nothing.
- Encryption at rest defends against *offline* recovery of the prefs file. It
does not defend against code already running as the host IDE's UID, which can
simply call `decrypt` — that would need a host-provided secure-storage
service with per-plugin isolation.
- **Shared crypto across two plugins.** `ai-core` performs the Gemini calls and
therefore has to read the same key. Both plugins run in the host IDE's process
(same UID) and so share one Android Keystore; each ships an identical copy of
`SecureApiKeyStore` pinned to the alias `cotg_ai_gemini_key_v1`. The two copies
must stay byte-identical apart from their package line — if `ALIAS`,
`TRANSFORM`, `IV_LEN` or `ENC_PREFIX` drift, `ai-core` silently fails to
decrypt and Gemini reports "backend not available". A host-provided
secure-storage service would remove the duplication.
- **Gemini API key in transit.** The key is sent to Google as an `x-goog-api-key`
request header (and via the SDK on the chat path), never in a URL query string.
- **File tools are confined to the project root** — see the in-IDE help page.
- **The context-file picker is confined to the open project** and fails closed:
with no project open it refuses to open rather than falling back to a broader
root.

## License

Expand Down
3 changes: 2 additions & 1 deletion ai-assistant/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@

<meta-data
android:name="plugin.max_ide_version"
android:value="26.30" />
android:value="26.31" />

<!-- Number of editor tabs contributed via getEditorTabs() (the "Agent" tab) -->
<meta-data
android:name="plugin.editor_tabs"
android:value="1" />

<!-- No network.access: ai-core makes the Gemini calls. No ide.settings: prefs reads aren't gated on it and nothing here modifies IDE settings. -->
<meta-data
android:name="plugin.permissions"
android:value="filesystem.read,filesystem.write,system.commands,project.structure" />
Expand Down
32 changes: 32 additions & 0 deletions ai-assistant/src/main/assets/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@ <h2>What the agent can do</h2>
device.
</div>

<h2>Attaching context files</h2>
<ul>
<li>Tap the <b>attach</b> button beside the prompt to pick files whose
contents are sent with your next message.</li>
<li>The picker is rooted at the project you have open and cannot browse
above it. With no project open it declines to open at all.</li>
<li><b>Toggle All</b> selects every file in the folder you are viewing;
<b>Add Selected</b> attaches them as chips above the prompt.</li>
<li>On the Gemini backend, attached file contents leave the device.</li>
</ul>

<h2>Your Gemini API key</h2>
<p>The key is encrypted with AES/GCM under a hardware-backed Android Keystore
secret, and only the ciphertext is written to the plugin's private storage —
a copied preferences file is useless without this device's Keystore. Clear it
any time from <b>Settings</b>. If the Keystore entry is ever lost — clearing the
app's data, for example — the stored key can no longer be read and will need to
be entered again; tapping <b>Edit</b> tells you when that has happened.</p>

<h2>Reading the conversation</h2>
<ul>
<li><b>Retry</b> appears on a message whose reply failed, and re-sends the
same prompt with the same attached files rather than adding a new one.</li>
<li><b>Open AI Settings</b> appears instead when the agent has not been
configured yet — no local model chosen, or no Gemini key saved.</li>
<li><b>System log</b> rows are collapsed records of the agent's internal
steps: tools run, files touched, backend errors. Tap the header to expand
one. They are saved with the session but are not sent to the model.</li>
<li>Long-press a message to copy its text, or to edit and re-send one of
your own.</li>
</ul>

<h2>Troubleshooting</h2>
<ul>
<li><b>"No model configured"</b> — select a <code>.gguf</code> file (Local)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,25 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {

const val TOOLTIP_TAG_TAB = "agent_chat_tab"

// Tags for the interactive controls on the Agent chat screen (see ChatFragment).
const val TOOLTIP_TAG_CONTEXT_FILES = "agent_context_files"
const val TOOLTIP_TAG_CHAT_INPUT = "agent_chat_input"
const val TOOLTIP_TAG_CHAT_SEND = "agent_chat_send"
const val TOOLTIP_TAG_CHAT_MENU = "agent_chat_menu"

// Tags for the controls rendered inside chat messages (see ChatAdapter).
const val TOOLTIP_TAG_MESSAGE_RETRY = "agent_message_retry"
const val TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS = "agent_message_open_settings"
const val TOOLTIP_TAG_SYSTEM_LOG = "agent_system_log"

// Tags for the interactive controls on the AI Settings dialog (see AiSettingsFragment).
const val TOOLTIP_TAG_SETTINGS_BACK = "ai_settings_back"
const val TOOLTIP_TAG_SETTINGS_BACKEND = "ai_settings_backend"
const val TOOLTIP_TAG_SETTINGS_LOCAL_MODEL = "ai_settings_local_model"
const val TOOLTIP_TAG_SETTINGS_LOCAL_SHA = "ai_settings_local_model_sha"
const val TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT = "ai_settings_simple_prompt"
const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_settings_gemini_key"
const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_settings_gemini_model"

@Volatile
private var pluginContext: PluginContext? = null
Expand Down Expand Up @@ -135,6 +150,126 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_CONTEXT_FILES,
summary = "Attach project files so the agent sees their contents with your next message.",
detail = """
<p>Opens a picker rooted at the <b>currently open project</b>; you
can browse subfolders but not above the project root, and the
picker won't open at all when no project is open.</p>
<p>Tap files to select them, <b>Toggle All</b> to select every file
in the folder you're viewing, then <b>Add Selected</b>. Attached
files appear as chips above the prompt — remove a chip to drop the
file again.</p>
<p>Contents are sent with your message, so on the Gemini backend
they leave the device.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_CHAT_INPUT,
summary = "Type your request here — questions, or instructions to change the project.",
detail = """
<p>Ask a question ("what does this class do?") or give an
instruction ("add a Room dependency"). Plain read-only requests
such as <i>open</i>, <i>read</i>, <i>list</i> and <i>search</i>
are recognised directly and run without going through the model,
so they work on every backend.</p>
<p>Anything that writes to the project asks for your approval
first.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_CHAT_SEND,
summary = "Send the prompt — turns into Stop while the agent is working.",
detail = """
<p>Sends your message to the selected backend. It stays disabled
until you type something.</p>
<p>While the agent is thinking or running tools this same button
becomes <b>Stop</b>: tapping it cancels the current turn, ends any
in-progress reply and discards the remaining tool steps.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_CHAT_MENU,
summary = "Agent menu: open AI Settings or start a new chat session.",
detail = """
<p>Opens the Agent's overflow menu:</p>
<ul>
<li><b>Settings</b> — choose the backend (Local or Gemini),
pick a model and manage your Gemini API key.</li>
<li><b>Clear chat</b> — starts a fresh session. The previous
conversation stays on disk in the plugin's own storage.</li>
</ul>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_MESSAGE_RETRY,
summary = "Send that message again after a failed reply.",
detail = """
<p>Appears on a message whose reply failed — a dropped network
request, a model that wasn't loaded, or a turn you stopped.</p>
<p><b>Retry</b> re-sends the same prompt with the same attached
context files; it does not add a new message to the conversation.
If it keeps failing, check the backend and model under
<b>Settings</b>.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS,
summary = "Jump to AI Settings to fix the problem this message reports.",
detail = """
<p>Shown when the agent could not run because it is not configured
yet — no local model selected, or no Gemini API key saved.</p>
<p>Opens <b>AI Settings</b> so you can choose a backend, pick a
<code>.gguf</code> model or enter a key, then return and send your
message again.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SYSTEM_LOG,
summary = "Collapsed system log — tap to expand the agent's internal steps.",
detail = """
<p>System entries record what the agent did behind the scenes: the
tools it ran, the files it touched and any errors the backend
reported.</p>
<p>They stay collapsed to keep the conversation readable — tap the
header to expand or collapse one. They are part of the saved
session, not messages sent to the model.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_BACK,
summary = "Close AI Settings and return to the Agent chat.",
detail = """
<p>Closes this dialog. Every setting here is saved as you change
it, so there is nothing to confirm — the Agent picks up the new
backend and model as soon as you return.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_BACKEND,
summary = "Choose which model powers the Agent: on-device Local (llama.cpp) or cloud Gemini.",
Expand Down Expand Up @@ -166,14 +301,68 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_LOCAL_SHA,
summary = "Optional SHA-256 of your .gguf file, checked when the model is loaded.",
detail = """
<p>Paste the expected SHA-256 hash of the model file. It is stored
with the model path and compared on load, so a truncated download
or a swapped file is reported instead of failing deep inside
llama.cpp.</p>
<p>Leave it empty to skip the check. The value is saved when the
field loses focus.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT,
summary = "Send small local models a plainer prompt with no tool instructions.",
detail = """
<p>Small on-device models (roughly 1B parameters and under) tend to
ramble or echo the prompt when handed the full tool-calling system
prompt. With this on they get a short, plain instruction instead.</p>
<p>The trade-off: the model won't emit tool calls, so it answers
questions but won't edit your project. The direct
<i>open/read/list/search</i> commands still work either way. Turn
it off for a larger instruct model.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_GEMINI_MODEL,
summary = "Pick which Gemini model to call; Refresh lists the ones your key can access.",
detail = """
<p><b>Refresh Models</b> asks Google which models your API key can
actually use and fills the list from the response. Until then the
list shows a small built-in set of known-good defaults.</p>
<p>Selecting a model saves it immediately. If a previously saved
model has since been retired, refreshing moves you to the first
model in the live list rather than leaving a name that returns
404.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_GEMINI_KEY,
summary = "Enter your Google Gemini API key. It is stored only on this device.",
detail = """
<p>Paste a Gemini API key to enable the cloud backend. The key is
kept in this plugin's private preferences on-device and is sent
only to Google's API over HTTPS. Requests (your prompts and
project context) leave the device when Gemini is selected.</p>
encrypted with a key held in this device's hardware-backed Android
Keystore before it is written to this plugin's private preferences,
and is sent only to Google's API over HTTPS. Requests (your prompts
and project context) leave the device when Gemini is selected.</p>
<p>Use the eye button to check what you typed, <b>Save</b> to store
it, <b>Edit</b> to change it later and <b>Clear</b> to remove it
from the device.</p>
<p>If the Keystore entry is ever lost — clearing the app's data,
for instance — the stored key can no longer be decrypted and must
be re-entered here.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
Expand Down
Loading