Make credential requests durable and store proxy authentication

This commit is contained in:
2026-11-06 21:13:40 +00:00
parent 16a9a9eb6c
commit abe420f0d4
12 changed files with 21279 additions and 62 deletions
+16 -2
View File
@@ -35,6 +35,20 @@ For Nix, use a separate npm derivation with `importNpmLock` and the checked-in l
Run the bundle script there. Pass its output to the Rust source derivation before Crane builds the host.
The Rust build must not execute npm or download JavaScript packages.
The console reads `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment.
It selects `openai/gpt-6.1-sol` with low reasoning effort.
The console reads the `llm-proxy` credentials item through Rust.
Its public `base_url` field contains the proxy URL. Its secret `api_key` field contains the token.
If the item does not exist, Rust creates it from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` once.
Rust encrypts the token before it commits the item. Later starts ignore these environment variables.
The token enters JavaScript only through an operation. No startup script, transcript, or world file stores it.
The console selects `openai/gpt-6.1-sol` with low reasoning effort.
This model rejects disabled reasoning. Tests use the faux provider without credentials.
Credential requests own background pi-durable tasks in their requesting conversations.
Filed notifications create tasks. Startup reconciliation also creates missing tasks, including requests decided during a host stop.
A conversation document records each request id in the same commit that creates its task.
Program requests create no task. A pending task sleeps durably and checks the decision each second.
The harness also expires requests during these checks.
A decided task admits one plain-language input and completes in one SQLite commit.
The input starts a new turn when the conversation is idle. Neither the input nor the task contains a secret.
The task and its input survive SIGKILL. The input uses a stable request id to prevent duplicate admission.
Hosts with a shared credentials handle can use `run_module_with_credentials`.
+102 -42
View File
@@ -33805,9 +33805,9 @@ async function createAgent(tx, conversation) {
function agentHooks(agent, taskName) {
const handlers = [];
for (const extension of agent.extensions) {
for (const hook of extension.hooks ?? []) {
if (hook.task === taskName)
handlers.push({ extension: extension.name, handlers: hook.handlers });
for (const hook2 of extension.hooks ?? []) {
if (hook2.task === taskName)
handlers.push({ extension: extension.name, handlers: hook2.handlers });
}
}
return handlers;
@@ -33815,7 +33815,7 @@ function agentHooks(agent, taskName) {
function sectionExtension(agent, key) {
let found;
for (const extension of agent.extensions) {
const provides = extension.sections?.some((section) => section.key === key) === true;
const provides = extension.sections?.some((section2) => section2.key === key) === true;
const wraps = extension.wraps?.some((wrap) => "section" in wrap && wrap.section === key) === true;
if (provides || wraps)
found = extension.name;
@@ -33840,8 +33840,8 @@ function resolveAgent(state2, snapshot, settings, report2) {
composed.set(tool.name, tool);
const sections = /* @__PURE__ */ new Map();
for (const extension of extensions) {
for (const section of extension.sections ?? [])
sections.set(section.key, section);
for (const section2 of extension.sections ?? [])
sections.set(section2.key, section2);
}
for (const extension of extensions) {
for (const wrap of extension.wraps ?? []) {
@@ -33849,7 +33849,7 @@ function resolveAgent(state2, snapshot, settings, report2) {
if ("tool" in wrap)
applyWrap(composed, wrap.tool, (tool) => wrap.wrap(tool), (tool) => tool.name, wrapReport);
else
applyWrap(sections, wrap.section, (section) => wrap.wrap(section), (section) => section.key, wrapReport);
applyWrap(sections, wrap.section, (section2) => wrap.wrap(section2), (section2) => section2.key, wrapReport);
}
}
const enabled = filterTools([...composed.values()], state2?.tools);
@@ -34685,24 +34685,24 @@ function replaySections(messages) {
}
async function renderSections(sections, input, shown, report2, context) {
const desired = /* @__PURE__ */ new Map();
for (const section of sections) {
for (const section2 of sections) {
let text2;
try {
text2 = await section.render(input, context);
text2 = await section2.render(input, context);
} catch (error) {
if (context.abortSignal?.aborted)
throw error;
report2(error, section);
const kept = shown.get(section.key);
report2(error, section2);
const kept = shown.get(section2.key);
if (kept !== void 0)
desired.set(section.key, kept);
desired.set(section2.key, kept);
continue;
}
if (text2 === void 0)
continue;
desired.set(section.key, section.tag === false ? text2 : `<${section.key}>
desired.set(section2.key, section2.tag === false ? text2 : `<${section2.key}>
${text2}
</${section.key}>`);
</${section2.key}>`);
}
return desired;
}
@@ -38706,11 +38706,11 @@ var ToolTask = defineTask({
return reject(runtime, task.input, call, tool, invalid(checked.error), context);
let args = checked.args;
let block;
await runtime.hooks.each("beforeTool", async (hook) => {
await runtime.hooks.each("beforeTool", async (hook2) => {
if (block !== void 0)
return;
try {
const decision = await hook({ ...call, arguments: args }, runtime, context);
const decision = await hook2({ ...call, arguments: args }, runtime, context);
if (decision?.block !== void 0)
block = decision.block;
else if (decision?.arguments !== void 0)
@@ -39052,8 +39052,8 @@ async function finalResult(runtime, input, call, tool, result, reported, context
details: result.details === void 0 ? reported.details : result.details,
diagnostics: [...reported.diagnostics, ...result.diagnostics ?? []]
};
await runtime.hooks.each("afterTool", async (hook) => {
final = await hook(call, final, runtime, context) ?? final;
await runtime.hooks.each("afterTool", async (hook2) => {
final = await hook2(call, final, runtime, context) ?? final;
});
if (retained !== void 0 && retained.droppedBytes > 0 && final.output === output) {
harness2.push(truncated(retained, reported.limits.retain));
@@ -39085,8 +39085,8 @@ async function finalResult(runtime, input, call, tool, result, reported, context
}
async function afterHarnessResult(runtime, input, call, tool, result, truncation, context) {
let final = result;
await runtime.hooks.each("afterTool", async (hook) => {
final = await hook(call, final, runtime, context) ?? final;
await runtime.hooks.each("afterTool", async (hook2) => {
final = await hook2(call, final, runtime, context) ?? final;
});
const harness2 = [];
if (truncation !== void 0 && final.output === result.output)
@@ -39406,9 +39406,9 @@ var GenerationTask = defineTask({
runtime.report(error, { kind: "host", callback: "env" });
}
const input = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };
const desired = await renderSections(agent.sections, input, shown, (error, section) => runtime.report(error, {
const desired = await renderSections(agent.sections, input, shown, (error, section2) => runtime.report(error, {
kind: "extension",
...optionalExtension(sectionExtension(agent, section.key))
...optionalExtension(sectionExtension(agent, section2.key))
}), context);
const entries2 = planSystemEntries(view2, desired, agent.tools, runtime.now());
const threshold = compacted === void 0 ? thresholdCompaction(view2, entries2, resolved.contextWindow, compactionPolicy(settings, model3)) : void 0;
@@ -39456,8 +39456,8 @@ var GenerationTask = defineTask({
return failNoModel(runtime, ref, context);
const view2 = await runtime.context(conversationId, context, { at: cutoff });
let messages = view2.messages;
await runtime.hooks.each("beforeRequest", async (hook) => {
const replaced = await hook({ messages }, runtime, context);
await runtime.hooks.each("beforeRequest", async (hook2) => {
const replaced = await hook2({ messages }, runtime, context);
if (replaced !== void 0)
messages = replaced.messages;
});
@@ -39545,8 +39545,8 @@ var GenerationTask = defineTask({
if (cutOffTooOften(task, runtime))
return endAnsweredCutOff(runtime, task, run2.answer, context);
const prepared = [];
await runtime.hooks.each("afterRun", async (hook) => {
const asked = await hook(run2, runtime, context);
await runtime.hooks.each("afterRun", async (hook2) => {
const asked = await hook2(run2, runtime, context);
if (asked !== void 0)
prepareRunEndTasks(runtime, asked.tasks, prepared);
});
@@ -39754,8 +39754,8 @@ async function convertPartial(tx, live, conversationId) {
}
async function providerPayload(runtime, model3, payload, context) {
let current = payload;
await runtime.hooks.each("beforeProviderRequest", async (hook) => {
const replaced = await hook({ model: model3, payload: current }, runtime, context);
await runtime.hooks.each("beforeProviderRequest", async (hook2) => {
const replaced = await hook2({ model: model3, payload: current }, runtime, context);
if (replaced !== void 0)
current = replaced.payload;
});
@@ -39839,7 +39839,7 @@ async function classify2(runtime, request2, message, context) {
}, context);
return;
}
await runtime.hooks.each("afterResponse", (hook) => hook(message, runtime, context));
await runtime.hooks.each("afterResponse", (hook2) => hook2(message, runtime, context));
const calls = message.content.filter((content) => content.type === "toolCall");
if (message.stopReason === "toolUse" && calls.length > 0) {
return startToolRound(runtime, request2, message, calls, context);
@@ -39892,10 +39892,10 @@ async function classify2(runtime, request2, message, context) {
}
async function answer(runtime, message, context) {
let continuation;
await runtime.hooks.each("onYield", async (hook) => {
await runtime.hooks.each("onYield", async (hook2) => {
if (continuation !== void 0)
return;
continuation = (await hook(message, runtime, context))?.continue;
continuation = (await hook2(message, runtime, context))?.continue;
});
const afterRun = await asksAfterRun(runtime, context);
const conversationId = runtime.conversationId;
@@ -40038,7 +40038,7 @@ async function finishToolRound(runtime, assistant, tools, context) {
});
const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? [];
const results = slots.flatMap((slot) => slot.entry === void 0 ? [] : [slot.entry]);
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));
await runtime.hooks.each("afterTools", (hook2) => hook2(assistant, results, runtime, context));
const ordered = slots.map((slot) => slot.taskId === void 0 ? void 0 : controls.get(slot.taskId));
const terminate = slots.length > 0 && ordered.every((control) => control?.terminate === true);
const added = ordered.flatMap((control) => control?.addTools ?? []);
@@ -40583,9 +40583,9 @@ var CompactionTask = defineTask({
...instructions === void 0 ? {} : { instructions }
};
let decision;
await runtime.hooks.each("beforeCompact", async (hook) => {
await runtime.hooks.each("beforeCompact", async (hook2) => {
if (decision === void 0)
decision = await hook(compaction, runtime, context);
decision = await hook2(compaction, runtime, context);
});
if (decision !== void 0 && "decline" in decision)
return complete(runtime, context);
@@ -40919,7 +40919,7 @@ var RegistryState = class {
return this.#extensions.flatMap((extension) => (extension.tools ?? []).map((tool) => ({ extension, tool })));
}
sections() {
return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section) => ({ extension, section })));
return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section2) => ({ extension, section: section2 })));
}
tasks() {
return [...this.#tasks.values()];
@@ -44424,10 +44424,67 @@ function environment(cwd = "/", conversation) {
return env;
}
// main.ts
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
throw new Error("PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required");
// credentials.ts
var Requests = defineDoc({
kind: "sausage.credential.requests",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({ ids: [] }),
checkpointWhen: () => false
});
var Wait = defineTask({
name: "sausage.credential.wait",
version: 1,
initial: () => ({ phase: "wait" }),
phases: {
wait: async (task, runtime, context) => {
const request2 = Deno.core.ops.op_credential_requests().find((r) => r.id === task.input.id);
if (!request2 || request2.state === "pending") {
await runtime.sleepUntil(Date.now() + 1e3, { phase: "wait" }, context);
return;
}
const conversation = await runtime.conversation(runtime.conversationId, context);
await runtime.commit(async (tx) => {
await conversation.submit(tx, { type: "input", content: request2.message, requestId: `credential:${request2.id}` });
return { status: "terminal", outcome: { status: "completed", result: null } };
}, context);
}
},
abort: (_task, runtime, context) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context)
});
var CredentialRequests = defineExtension({ name: "sausage.credentials", tasks: [Wait] });
async function discoverRequests(harness2, context) {
for (const request2 of Deno.core.ops.op_credential_requests()) {
const conversation = await harness2.conversation(Number(request2.conversation), context);
if (!conversation) continue;
await conversation.commit(async (tx) => {
const recorded = await tx.doc(Requests, conversation.id);
if (recorded.ids.includes(request2.id)) return;
await tx.createTask(Wait, { id: request2.id }, { ownership: { kind: "conversation" }, background: true });
recorded.ids.push(request2.id);
}, context);
}
}
async function startRequests(harness2, context) {
await discoverRequests(harness2, context);
let active = true;
const pending = (async () => {
while (active) {
await Deno.core.ops.op_credential_next();
if (active) await discoverRequests(harness2, context);
}
})();
return async () => {
active = false;
await pending;
};
}
// main.ts
var proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error("The llm-proxy credential is required");
var models = createModels();
var provider2 = openaiProvider();
var model2 = {
@@ -44435,7 +44492,7 @@ var model2 = {
name: "gpt-6.1-sol",
provider: "openai",
api: "openai-responses",
baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`,
baseUrl: `${proxy.base_url}/v1`,
reasoning: true,
input: ["text"],
thinkingLevelMap: { off: null, minimal: null },
@@ -44449,21 +44506,22 @@ models.setProvider({
getModels: () => [model2],
auth: { apiKey: { name: "Sausage proxy", resolve: async () => ({
auth: {
apiKey: process.env.PI_PROXY_API_KEY,
apiKey: proxy.api_key,
baseUrl: model2.baseUrl,
headers: { "Shopify-Usage-Tag": JSON.stringify(["pi", "sausage"]) }
},
source: "PI_PROXY_API_KEY"
source: "llm-proxy"
}) } }
});
var registry = createRegistry();
registry.install(CodingTools);
registry.install(CredentialRequests);
var harness = await Harness.open(await openStorage(globalThis.storagePath), {
models,
registry,
env: (target) => environment(target.cwd ?? "/", target.conversationId)
}, BACKGROUND_CONTEXT);
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools] } });
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools, CredentialRequests] } });
var watch = await root.watch(BACKGROUND_CONTEXT);
var emitted = /* @__PURE__ */ new Map();
var partial = "";
@@ -44488,6 +44546,7 @@ for (const entry2 of watch.value.entries) {
watch.start((event) => {
if (event.kind === "value") render(event.value);
});
var stopRequests = await startRequests(harness, BACKGROUND_CONTEXT);
harness.resume();
await harness.waitForIdle(BACKGROUND_CONTEXT);
while (true) {
@@ -44503,5 +44562,6 @@ while (true) {
Deno.core.ops.op_output("\n");
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
}
await stopRequests();
await watch.stop();
await harness.close(BACKGROUND_CONTEXT);
+49
View File
@@ -0,0 +1,49 @@
// Durable, background credential waits. Admission and completion share one SQLite commit.
import { defineDoc, defineTask, defineExtension } from '@earendil-works/pi-durable';
const Requests = defineDoc<{ ids: string[] }>({
kind: 'sausage.credential.requests', version: 1, scope: 'conversation',
history: 'latest', fork: 'initial', initial: () => ({ ids: [] }), checkpointWhen: () => false,
});
const Wait = defineTask<{ id: string }, { phase: 'wait' }, null>({
name: 'sausage.credential.wait', version: 1, initial: () => ({ phase: 'wait' }),
phases: {
wait: async (task, runtime, context) => {
const request = Deno.core.ops.op_credential_requests().find((r: any) => r.id === task.input.id);
if (!request || request.state === 'pending') {
await runtime.sleepUntil(Date.now() + 1000, { phase: 'wait' }, context);
return;
}
const conversation = (await runtime.conversation(runtime.conversationId, context))!;
await runtime.commit(async (tx) => {
await conversation.submit(tx, { type: 'input', content: request.message, requestId: `credential:${request.id}` });
return { status: 'terminal', outcome: { status: 'completed', result: null } };
}, context);
},
},
abort: (_task, runtime, context) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), context),
});
export const CredentialRequests = defineExtension({ name: 'sausage.credentials', tasks: [Wait] });
export async function discoverRequests(harness: any, context: any) {
for (const request of Deno.core.ops.op_credential_requests()) {
const conversation = await harness.conversation(Number(request.conversation), context);
if (!conversation) continue;
await conversation.commit(async (tx: any) => {
const recorded = await tx.doc(Requests, conversation.id);
if (recorded.ids.includes(request.id)) return;
await tx.createTask(Wait, { id: request.id }, { ownership: { kind: 'conversation' }, background: true });
recorded.ids.push(request.id);
}, context);
}
}
export async function startRequests(harness: any, context: any) {
await discoverRequests(harness, context);
// Filed notifications start waits. The deadline reconciles other handles and lost notifications.
let active = true;
const pending = (async () => {
while (active) {
await Deno.core.ops.op_credential_next();
if (active) await discoverRequests(harness, context);
}
})();
return async () => { active = false; await pending; };
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
// Faux-model acceptance probe for credential wake-ups, including host restart.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxProvider, fauxAssistantMessage, fauxText } from '@earendil-works/pi-ai/providers/faux';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { openStorage } from './sqlite';
import { CredentialRequests, startRequests } from './credentials';
const models = createModels();
const faux = fauxProvider({ tokensPerSecond: 100000 });
models.setProvider(faux.provider);
faux.setResponses(Array.from({ length: 8 }, () => fauxAssistantMessage([fauxText('done')])));
const registry = createRegistry();
registry.install(CredentialRequests);
const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry }, context);
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CredentialRequests] } });
await (await root.submit({ type: 'input', content: 'initial', requestId: 'initial' }, context)).wait(context);
if (globalThis.proxyOnly) {
await harness.close(context);
} else {
await Deno.core.ops.op_file('write', '/conversation', Array.from(new TextEncoder().encode(root.id)));
const stop = await startRequests(harness, context);
harness.resume();
const watch = await root.watch(context);
await new Promise<void>((resolve, reject) => {
const check = (event: any) => {
if (event.kind !== 'value') return;
const messages = event.value.entries.filter((e: any) => JSON.stringify(e).includes('Your request for'));
if (messages.length > 1) reject(new Error('Duplicate credential message'));
if (messages.length === 1) {
const message = JSON.stringify(messages);
if (!message.includes('`github`') || !message.includes(globalThis.expectedState)) reject(new Error('Wrong credential decision message'));
else resolve();
}
};
watch.start(check);
check({ kind: 'value', value: watch.value });
});
await harness.waitForIdle(context);
const final = await root.watch(context);
if (final.value.entries.filter((entry: any) => entry.kind === 'pi.assistant').length !== 2) {
throw new Error('The decision must start exactly one model turn');
}
await final.stop();
await stop();
await watch.stop();
await harness.close(context);
}
+10 -7
View File
@@ -6,30 +6,31 @@ import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { openStorage } from './sqlite';
import { environment } from './environment';
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
throw new Error('PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required');
}
import { CredentialRequests, startRequests } from './credentials';
const proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
const models = createModels();
const provider = openaiProvider();
const model = {
id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses',
baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`, reasoning: true, input: ['text'],
baseUrl: `${proxy.base_url}/v1`, reasoning: true, input: ['text'],
thinkingLevelMap: { off: null, minimal: null },
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096,
};
models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model],
auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({
auth: { apiKey: process.env.PI_PROXY_API_KEY, baseUrl: model.baseUrl,
auth: { apiKey: proxy.api_key, baseUrl: model.baseUrl,
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
source: 'PI_PROXY_API_KEY',
source: 'llm-proxy',
}) } },
});
const registry = createRegistry();
registry.install(CodingTools);
registry.install(CredentialRequests);
const harness = await Harness.open(await openStorage(globalThis.storagePath), {
models, registry, env: (target: any) => environment(target.cwd ?? '/', target.conversationId),
}, context);
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools] } });
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools, CredentialRequests] } });
const watch = await root.watch(context);
const emitted = new Map<number, string>();
let partial = '';
@@ -53,6 +54,7 @@ for (const entry of watch.value.entries) {
if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0]));
}
watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
const stopRequests = await startRequests(harness, context);
harness.resume();
await harness.waitForIdle(context);
while (true) {
@@ -68,5 +70,6 @@ while (true) {
Deno.core.ops.op_output('\n');
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
}
await stopRequests();
await watch.stop();
await harness.close(context);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js" },
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
+1
View File
@@ -182,6 +182,7 @@ impl Cred {
if let Some(expired) = expired_id {
let _ = self.decided.send(expired);
}
let _ = self.filed.send(id.clone());
Ok(id)
}
pub fn request_list(&self) -> Result<Vec<Request>> {
+6
View File
@@ -16,6 +16,7 @@ pub(crate) struct Store {
pub struct Cred {
pub(crate) store: Mutex<Store>,
pub(crate) decided: tokio::sync::broadcast::Sender<String>,
pub(crate) filed: tokio::sync::broadcast::Sender<String>,
}
impl Cred {
pub fn open(host_dir: &Path) -> Result<Self> {
@@ -50,9 +51,11 @@ impl Cred {
db.busy_timeout(std::time::Duration::from_secs(5))?;
db.execute_batch(schema::DDL)?;
let (decided, _) = tokio::sync::broadcast::channel(64);
let (filed, _) = tokio::sync::broadcast::channel(64);
let cred = Self {
store: Mutex::new(Store { db, master }),
decided,
filed,
};
cred.expire_requests()?;
Ok(cred)
@@ -60,6 +63,9 @@ impl Cred {
pub(crate) fn access(&self) -> Result<MutexGuard<'_, Store>> {
self.store.lock().map_err(|_| Error("storage"))
}
pub fn subscribe_filed(&self) -> tokio::sync::broadcast::Receiver<String> {
self.filed.subscribe()
}
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<String> {
self.decided.subscribe()
}
+162
View File
@@ -0,0 +1,162 @@
//! Credential request snapshots and host-only model proxy configuration.
use crate::cred::{store::Cred, types::Subject};
use deno_core::{OpState, op2};
use deno_error::JsErrorBox;
use serde_json::{Value, json};
use std::{cell::RefCell, rc::Rc, sync::Arc};
pub(super) struct Notifications {
filed: tokio::sync::Mutex<tokio::sync::broadcast::Receiver<String>>,
}
impl Notifications {
pub(super) fn new(cred: &Cred) -> Rc<Self> {
Rc::new(Self {
filed: tokio::sync::Mutex::new(cred.subscribe_filed()),
})
}
}
#[op2]
pub(super) async fn op_credential_next(state: Rc<RefCell<OpState>>) {
let notifications = state.borrow().borrow::<Rc<Notifications>>().clone();
let mut filed = notifications.filed.lock().await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), filed.recv()).await;
}
#[op2]
#[serde]
pub(super) fn op_credential_requests(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
let cred = state.borrow::<Arc<Cred>>();
let work = || -> anyhow::Result<Value> {
cred.expire_requests()?;
// TODO(T27): prune retained records once a day after the library adds Cred::prune.
let mut rows = Vec::new();
for request in cred.request_list()? {
let Subject::Conversation(conversation) = request.subject else {
continue;
};
let names = request
.payload
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(|id| {
cred.item_get(id)
.map(|item| format!("`{}`", item.name))
.unwrap_or_else(|_| "the credential".into())
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| "the credential change".into());
let message = match request.state.as_str() {
"approved" => format!("Your request for {names} was approved. Retry the command."),
"denied" => format!("Your request for {names} was denied."),
"expired" => format!(
"Your request for {names} expired. File a new request if you still need it."
),
_ => String::new(),
};
rows.push(json!({"id":request.id,"conversation":conversation,"state":request.state,"message":message}));
}
Ok(json!(rows))
};
work().map_err(|_| JsErrorBox::generic("Cannot read credential requests"))
}
pub(super) fn proxy(cred: &Cred) -> anyhow::Result<Option<Value>> {
use crate::cred::{
items::{Field, Item},
types::{Caller, FieldType},
};
let caller = Caller {
pid: 0,
program: "host".into(),
conversations: vec![],
};
let item = cred
.item_list()?
.into_iter()
.find(|item| item.name == "llm-proxy");
let item = match item {
Some(item) => item,
None => {
let (Ok(base), Ok(key)) = (
std::env::var("PI_PROXY_BASE_URL"),
std::env::var("PI_PROXY_API_KEY"),
) else {
return Ok(None);
};
let mut store = cred.access()?;
let master = store.master.clone();
let tx = store
.db
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let mut item = crate::cred::items::insert_new(
&tx,
Item {
id: String::new(),
name: "llm-proxy".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![
Field {
field_id: String::new(),
name: "base_url".into(),
public: true,
value: Some(base),
needs_value: false,
field_type: FieldType::Text,
key_type: None,
},
Field {
field_id: String::new(),
name: "api_key".into(),
public: false,
value: None,
needs_value: true,
field_type: FieldType::Text,
key_type: None,
},
],
},
&caller,
Some("host bootstrap"),
)?;
let field = &item
.fields
.iter()
.find(|f| f.name == "api_key")
.unwrap()
.field_id;
let sealed =
crate::cred::seal::seal(&master, &format!("{}|{field}", item.id), key.as_bytes())?;
tx.execute(
"INSERT INTO fields VALUES(?1,?2,?3)",
rusqlite::params![item.id, field, sealed],
)?;
item.fields
.iter_mut()
.find(|f| f.name == "api_key")
.unwrap()
.needs_value = false;
crate::cred::items::save(&tx, &item)?;
tx.commit()?;
item
}
};
Ok(Some(cred.credential(&item, true)?))
}
#[op2]
#[serde]
pub(super) fn op_model_proxy(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
proxy(state.borrow::<Arc<Cred>>())
.map(|v| v.unwrap_or(Value::Null))
.map_err(|_| JsErrorBox::generic("Cannot read llm-proxy credential"))
}
+41 -10
View File
@@ -1,4 +1,5 @@
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
mod credentials;
mod environment;
use crate::{exec::Exec, world::World};
use deno_core::{JsRuntime, OpState, RuntimeOptions, op2};
@@ -78,6 +79,9 @@ deno_core::extension!(
storage,
ops = [
op_sql,
credentials::op_credential_requests,
credentials::op_model_proxy,
credentials::op_credential_next,
environment::op_file,
environment::op_exec,
environment::op_exec_next,
@@ -112,6 +116,18 @@ pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> anyhow::Result<
/// Evaluate a bundled harness module with the same storage and tool operations as the console.
/// The caller must use a current-thread Tokio runtime.
pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyhow::Result<()> {
let cred = Arc::new(crate::cred::store::Cred::open(&world.host_dir)?);
run_module_with_credentials(world, exec, cred, source).await
}
/// Run a harness with the credentials handle shared by the host API.
pub async fn run_module_with_credentials(
world: World,
exec: Arc<dyn Exec>,
cred: Arc<crate::cred::store::Cred>,
source: &str,
) -> anyhow::Result<()> {
let proxy = credentials::proxy(&cred)?;
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_telemetry::deno_telemetry::init(),
@@ -162,11 +178,11 @@ pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyh
deno_permissions::RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
)));
runtime.execute_script("bootstrap.js", include_str!("../../js/bootstrap.js"))?;
let env: serde_json::Map<String, Value> = ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"]
.into_iter()
.filter_map(|key| std::env::var(key).ok().map(|v| (key.to_owned(), json!(v))))
.collect();
runtime.execute_script("env.js", format!("process.env = {};", Value::Object(env)))?;
runtime
.op_state()
.borrow_mut()
.put(credentials::Notifications::new(&cred));
runtime.op_state().borrow_mut().put(cred);
runtime
.op_state()
.borrow_mut()
@@ -186,11 +202,26 @@ pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyh
.load_main_es_module_from_code(&specifier, source.to_owned())
.await?;
let result = runtime.mod_evaluate(module);
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok(())
let result = async {
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok::<_, anyhow::Error>(())
}
.await;
result.map_err(|error| {
let mut text = error.to_string();
if let Some(key) = proxy
.as_ref()
.and_then(|v| v.get("api_key"))
.and_then(Value::as_str)
&& !key.is_empty()
{
text = text.replace(key, "[redacted]");
}
anyhow::anyhow!(text)
})
}
/// Open a world and resume its durable root conversation.
+325
View File
@@ -0,0 +1,325 @@
//! Credential decisions wake an idle faux-model conversation through durable tasks.
use sausage::{
cred::{
items::Item,
requests::{GrantRequest, Overrides},
store::Cred,
types::Caller,
},
harness::{run_module, run_module_with_credentials},
world::World,
};
use std::sync::Arc;
#[tokio::test(flavor = "current_thread")]
async fn restart_child() -> anyhow::Result<()> {
let Ok(dir) = std::env::var("DURABLE_RESTART_DIR") else {
return Ok(());
};
let dir = std::path::Path::new(&dir);
let source = format!(
"globalThis.expectedState = 'approved';\n{}",
include_str!("../js/durable-test-bundle.js")
);
run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
&source,
)
.await
}
#[test]
fn sigkill_preserves_request_wait() -> anyhow::Result<()> {
use std::{
process::Command,
time::{Duration, Instant},
};
let dir = std::env::temp_dir().join(format!("sausage-durable-restart-{}", std::process::id()));
let world = World::open(&dir)?;
let cred = Cred::open(&world.host_dir)?;
let admin = Caller {
pid: 0,
program: "test".into(),
conversations: vec![],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "github".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![],
},
&admin,
"test",
)?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.env("DURABLE_RESTART_DIR", &dir)
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
let conversation = loop {
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
&& !id.is_empty()
{
break id;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Conversation did not start");
}
std::thread::yield_now();
};
let caller = Caller {
conversations: vec![conversation],
..admin
};
let id = cred.file_request(
&caller,
"restart",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
loop {
let count: i64 = db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
if count == 1 {
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Durable wait did not start");
}
std::thread::yield_now();
}
child.kill()?;
child.wait()?;
cred.approve(&id, "yes", Overrides::default(), Some("test"))?;
for _ in 0..2 {
let mut resumed = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.env("DURABLE_RESTART_DIR", &dir)
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = resumed.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
resumed.kill()?;
resumed.wait()?;
anyhow::bail!("Restart did not finish");
}
std::thread::yield_now();
}
}
drop(db);
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn proxy_child() -> anyhow::Result<()> {
let Ok(dir) = std::env::var("DURABLE_PROXY_DIR") else {
return Ok(());
};
let dir = std::path::Path::new(&dir);
let source = format!(
"globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}",
include_str!("../js/durable-test-bundle.js")
);
run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
&source,
)
.await?;
let error = run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
"throw new Error(Deno.core.ops.op_model_proxy().api_key)",
)
.await
.unwrap_err();
assert!(!error.to_string().contains("faux-private-token"));
assert!(error.to_string().contains("[redacted]"));
Ok(())
}
#[test]
fn proxy_bootstrap_once() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id()));
for start in 0..2 {
let status = std::process::Command::new(std::env::current_exe()?)
.args(["--exact", "proxy_child", "--nocapture"])
.env("DURABLE_PROXY_DIR", &dir)
.env(
"PI_PROXY_BASE_URL",
if start == 0 {
"https://proxy.invalid"
} else {
"https://ignored.invalid"
},
)
.env(
"PI_PROXY_API_KEY",
if start == 0 {
"faux-private-token"
} else {
"ignored-token"
},
)
.status()?;
assert!(status.success());
}
let cred = Cred::open(&dir.join("host"))?;
assert_eq!(
cred.item_list()?
.iter()
.filter(|i| i.name == "llm-proxy")
.count(),
1
);
for file in [
"host/cred.sqlite",
"host/cred.sqlite-wal",
"host/harness.sqlite",
"host/harness.sqlite-wal",
] {
if let Ok(bytes) = std::fs::read(dir.join(file)) {
assert!(
!bytes
.windows(b"faux-private-token".len())
.any(|w| w == b"faux-private-token")
);
}
}
assert!(!dir.join("world/conversation").exists());
drop(cred);
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn decisions_wake_idle_conversation() -> anyhow::Result<()> {
for state in ["approved", "denied", "expired"] {
let dir =
std::env::temp_dir().join(format!("sausage-durable-{}-{state}", std::process::id()));
let world = World::open(&dir)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let admin = Caller {
pid: 0,
program: "test".into(),
conversations: vec![],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "github".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![],
},
&admin,
"test",
)?;
cred.file_request(
&admin,
"program",
GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let source = format!(
"globalThis.expectedState = {state:?};\n{}",
include_str!("../js/durable-test-bundle.js")
);
let decide = async {
let conversation = loop {
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
&& !id.is_empty()
{
break id;
}
tokio::task::yield_now().await;
};
let caller = Caller {
conversations: vec![conversation],
..admin
};
let id = cred.file_request(
&caller,
"test",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let harness_db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
loop {
let count: i64 = harness_db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
if count == 1 {
break;
}
tokio::task::yield_now().await;
}
match state {
"approved" => cred.approve(&id, "yes", Overrides::default(), Some("test"))?,
"denied" => cred.deny(&id, "no", Some("test"))?,
_ => {
let db = rusqlite::Connection::open(dir.join("host/cred.sqlite"))?;
db.execute("UPDATE requests SET expires=0 WHERE id=?", [&id])?;
}
}
Ok::<_, anyhow::Error>(())
};
let exec = sausage::process::exec(World::open(&dir)?)?;
tokio::time::timeout(std::time::Duration::from_secs(20), async {
let (a, b) = tokio::join!(
run_module_with_credentials(world, Arc::clone(&exec), cred.clone(), &source),
decide
);
a?;
b?;
Ok::<_, anyhow::Error>(())
})
.await??;
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
let tasks: i64 = db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
assert_eq!(tasks, 1, "Program requests must create no durable task");
drop(db);
std::fs::remove_dir_all(dir)?;
}
Ok(())
}