Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f15d7fc
model: custom provider endpoints — base-URL overrides and admin-regis…
ReganBell Aug 4, 2026
346388d
deploy: owner app shell — artifact-style top bar with a docked iterat…
ReganBell Aug 4, 2026
2fee4d7
Never show a raw Slack id where a person will read it
ReganBell Aug 4, 2026
ea1fa61
web-ui: pre-fork origin badge, author-labeled bubbles, real sidebar l…
ReganBell Aug 4, 2026
a7b5c8b
Persist full runtime defaults per scope
ReganBell Aug 4, 2026
e23b301
self-API: spawn a fresh conversation
ReganBell Aug 4, 2026
4b408da
dev-instance: browser-only mode (--no-slack) needs no Slack pool slot
ReganBell Aug 4, 2026
6d1f6cf
docs(security): record the three deliberately portal-only actions
ReganBell Aug 4, 2026
fd890a8
credential broker: refuse percent-encoded parent traversal in paths
ReganBell Aug 4, 2026
ee7344b
Merge pull request #198 from yc-software/upstream-broker-path-traversal
ReganBell Aug 4, 2026
cec39b7
Merge pull request #199 from yc-software/upstream-no-raw-slack-ids
ReganBell Aug 4, 2026
2b5e83e
Merge pull request #200 from yc-software/upstream-custom-providers
ReganBell Aug 4, 2026
27e2ff3
Merge pull request #202 from yc-software/upstream-web-ui-polish
ReganBell Aug 4, 2026
db21870
Merge pull request #203 from yc-software/upstream-runtime-defaults
ReganBell Aug 4, 2026
4021b1c
Merge pull request #204 from yc-software/upstream-spawn-conversation
ReganBell Aug 4, 2026
6e7d03e
Merge pull request #205 from yc-software/upstream-dev-no-slack
ReganBell Aug 4, 2026
271a424
Merge pull request #206 from yc-software/upstream-portal-only-docs
ReganBell Aug 4, 2026
908eae9
test: assert portal origin with a plain substring check
ReganBell Aug 4, 2026
47a6d6c
CONTRIBUTING: add bug-report / issue guidance
ReganBell Aug 4, 2026
d550d99
test: assert the shell's portal origin by exact value
ReganBell Aug 4, 2026
7ad80d0
Merge pull request #201 from yc-software/upstream-app-shell
ReganBell Aug 4, 2026
866764e
CONTRIBUTING: clarify feature PRs vs bug issues
ReganBell Aug 4, 2026
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
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

We'd like to try something a little different with this repo.

Given that coding agents write most underlying code now, we'd prefer PRs in the form of _human-written_
Given that coding agents write most underlying code now, we'd prefer `feature` PRs in the form of _human-written_
text. This can be quite informal — just run your idea by us in the same way you would a coworker or
friend, say, over Slack. If we're aligned on the change, we're happy to burn our tokens
on the underlying implementation.
Expand All @@ -11,4 +11,6 @@ Please do not have AI artificially expand what you'd like to do into a formal pr

Submit changes as a PR adding a `.txt` or `.md` file to the [`adrs/`](./adrs/) folder.

For bugs, just open an issue. We appreciate this a lot, and will credit you as co-author on the commit if we merge a fix.

PS: Report any security vulnerabilities privately — see [`SECURITY.md`](./SECURITY.md), not a public issue.
23 changes: 23 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ plaintext credentials while a process is using them. An approval means a human
accepted the displayed action under the information available at that time, not that
the resulting behavior is safe.

### Deliberately portal-only actions

Three actions are intentionally excluded from the agent self-API, even though the
web portal offers them. They look like capability-parity gaps in an audit; they are
walls, not gaps, and should not be "fixed" without revisiting the reasoning here.

- **Admin grant changes.** Granting or revoking org-admin rights happens only in the
portal, on an authenticated admin's own turn. If the agent could change grants, a
prompt-injected or compromised agent process could escalate its own operator's
privileges — or demote everyone else's.
- **Impersonation.** The agent always acts as the principal resolved for the turn.
There is no self-API route to act as a different principal, because every
authorization decision downstream keys off that identity; a switchable identity
would turn one confused turn into another person's authority.
- **Command-approval decisions.** Approving a gated command is a human judgment made
on the approver's own turn. An agent-reachable approval route would collapse the
human-in-the-loop gate into a single model decision, which is exactly what the
gate exists to prevent.

The common shape: each is a decision that authorizes _future_ agent behavior, so the
decision itself must come from outside the agent. Parity work should route around
these, not through them.

### Known limitations

- **Command policy is bypassable.** It classifies shell text and catches configured or
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"start": "node --env-file-if-exists=.env src/index.ts",
"dev": "SHUTDOWN_DRAIN_MS=2000 node --env-file-if-exists=.env --watch src/index.ts",
"dev-instance": "bash scripts/dev-instance.sh up",
"dev-instance:no-slack": "bash scripts/dev-instance.sh up --no-slack",
"dev-instance:status": "bash scripts/dev-instance.sh status",
"dev-instance:down": "bash scripts/dev-instance.sh down",
"worker": "node --env-file-if-exists=.env src/runs/worker-main.ts",
Expand Down
168 changes: 168 additions & 0 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3905,6 +3905,72 @@ <h2>Base model</h2>
><span class="status" id="st-onboarding-model"></span>
</div>
</section>
<section class="card">
<div class="head">
<h2>Custom providers</h2>
<p>
Point QM at any OpenAI- or Anthropic-compatible endpoint — a vendor like DeepSeek, or a gateway like
LiteLLM fronting many models. Keys are validated, stored write-only, and the models join the picker.
</p>
</div>
<div class="body">
<table class="table" id="custom-provider-table">
<thead>
<tr>
<th>Provider</th>
<th>Protocol</th>
<th>Endpoint</th>
<th>Models</th>
<th>Key</th>
<th></th>
</tr>
</thead>
<tbody id="custom-provider-rows"></tbody>
</table>
<p class="muted" id="custom-provider-empty" hidden>No custom providers registered.</p>
</div>
<div class="body setup-form">
<label
>Provider id (slug) <input id="custom-provider-id" autocomplete="off" placeholder="litellm"
/></label>
<label>Display name <input id="custom-provider-name" autocomplete="off" placeholder="LiteLLM" /></label>
<label
>Protocol
<select id="custom-provider-protocol">
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic-compatible</option>
</select></label
>
<label
>Base URL
<input id="custom-provider-url" autocomplete="off" placeholder="https://gateway.internal/v1"
/></label>
<label
>API key
<input
type="password"
id="custom-provider-key"
autocomplete="off"
placeholder="Write-only; blank on edit keeps the stored key"
/></label>
<label
>Models (one per line: id | name | context | maxTokens)
<textarea
id="custom-provider-models"
rows="3"
placeholder="deepseek-chat | DeepSeek V3.2 | 128000 | 8192"
></textarea>
</label>
<label class="inline"
><input type="checkbox" id="custom-provider-validate" checked /> Validate key against the endpoint
(untick for gateways without a models listing)</label
>
</div>
<div class="foot">
<button class="primary" id="custom-provider-save">Save provider</button
><span class="status" id="st-custom-provider"></span>
</div>
</section>
</div>

<div id="view-governance">
Expand Down Expand Up @@ -7044,6 +7110,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
$("onboarding-model-save").disabled = false;
$("onboarding-model-key").value = "";
await loadOnboarding();
await loadCustomProviders();
setStatus(
"st-onboarding-model",
selected.ok ? "Key and base model saved." : "Key saved, but the base model could not be changed.",
Expand All @@ -7065,8 +7132,109 @@ <h2 id="governance-review-title">Confirm governance change</h2>
return;
}
await loadOnboarding();
await loadCustomProviders();
setStatus("st-onboarding-model", "Provider disabled.", "ok");
};
let customProvidersLoaded = [];
function parseCustomModels(text) {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [id, name, contextWindow, maxTokens] = line.split("|").map((part) => part.trim());
const model = { id };
if (name) model.name = name;
if (contextWindow) model.contextWindow = Number(contextWindow);
if (maxTokens) model.maxTokens = Number(maxTokens);
return model;
});
}
async function loadCustomProviders() {
const res = await api("GET", "/api/custom-providers");
if (!res.ok) return;
customProvidersLoaded = (res.data?.providers || []).filter((provider) => !provider.disabled);
const rows = $("custom-provider-rows");
rows.textContent = "";
$("custom-provider-empty").hidden = customProvidersLoaded.length > 0;
customProvidersLoaded.forEach((provider) => {
const tr = document.createElement("tr");
const cells = [
provider.name + " (" + provider.id + ")",
provider.protocol === "anthropic" ? "Anthropic" : "OpenAI",
provider.baseUrl,
provider.models.map((model) => model.id).join(", "),
provider.hasKey ? "set (write-only)" : "none",
];
cells.forEach((textContent) => {
const td = document.createElement("td");
td.textContent = textContent;
tr.appendChild(td);
});
const actions = document.createElement("td");
const edit = document.createElement("button");
edit.textContent = "Edit";
edit.onclick = () => {
$("custom-provider-id").value = provider.id;
$("custom-provider-name").value = provider.name;
$("custom-provider-protocol").value = provider.protocol;
$("custom-provider-url").value = provider.baseUrl;
$("custom-provider-key").value = "";
$("custom-provider-models").value = provider.models
.map((model) =>
[model.id, model.name, model.contextWindow, model.maxTokens].filter((part) => part != null).join(" | "),
)
.join("\n");
};
const remove = document.createElement("button");
remove.className = "danger";
remove.textContent = "Remove";
remove.onclick = async () => {
if (!confirm("Remove " + provider.name + "? Its models leave every model picker.")) return;
const removed = await api("DELETE", "/api/custom-providers/" + encodeURIComponent(provider.id));
if (!removed.ok) {
setStatus("st-custom-provider", removed.data?.message || "Could not remove this provider.", "err");
return;
}
await loadCustomProviders();
setStatus("st-custom-provider", "Provider removed.", "ok");
};
actions.appendChild(edit);
actions.appendChild(remove);
tr.appendChild(actions);
rows.appendChild(tr);
});
}
$("custom-provider-save").onclick = async () => {
const id = $("custom-provider-id").value.trim();
const name = $("custom-provider-name").value.trim();
const baseUrl = $("custom-provider-url").value.trim();
const models = parseCustomModels($("custom-provider-models").value);
if (!id || !name || !baseUrl || models.length === 0) {
setStatus("st-custom-provider", "Provider id, name, base URL, and at least one model are required.", "err");
return;
}
const apiKey = $("custom-provider-key").value.trim();
const body = {
name,
protocol: $("custom-provider-protocol").value,
baseUrl,
models,
...(apiKey ? { apiKey } : {}),
...($("custom-provider-validate").checked ? {} : { validate: false }),
};
$("custom-provider-save").disabled = true;
setStatus("st-custom-provider", "Saving…", "saving", true);
const saved = await api("PUT", "/api/custom-providers/" + encodeURIComponent(id), body);
$("custom-provider-save").disabled = false;
if (!saved.ok) {
setStatus("st-custom-provider", saved.data?.message || "Could not save this provider.", "err", true);
return;
}
$("custom-provider-key").value = "";
await loadCustomProviders();
setStatus("st-custom-provider", "Provider saved. Its models are now in the picker.", "ok");
};
function openOnboardingTarget(target) {
setView("connectors");
setTimeout(
Expand Down
2 changes: 2 additions & 0 deletions plugins/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ const WRITES = new Map<string, string[]>([
["users", ["PUT", "POST"]],
["slack-installation", ["PUT", "DELETE"]],
["model-providers", ["PUT", "DELETE"]],
["custom-providers", ["PUT", "DELETE"]],
]);

const READS = [
Expand All @@ -291,6 +292,7 @@ const READS = [
"ack-emoji-picks",
"slack-installation",
"model-providers",
"custom-providers",
];

const server = createServer((req, res) => {
Expand Down
7 changes: 0 additions & 7 deletions plugins/web-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading