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
1 change: 1 addition & 0 deletions .github/release-workers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ standard_workers:
- pubsub
- queue
- rbac-proxy
- sandbox-code-runner
- session-manager
- slack
- telegram-bot
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ npx skills add iii-hq/iii --all
| [`computer`](computer/) | Rust | Full-desktop computer use — start a session on this machine, a sandboxed desktop, or a remote one, screenshot it, click and type by coordinate, and stream the live screen into the console. |
| [`worktree`](worktree/) | Rust | Git worktree lifecycle for parallel agents — `worktree::*` mint, claim, and track isolated worktrees per repo, emit six lifecycle trigger types, and land branches back through a per-repo FIFO queue (rebase, test gate, ff-only merge). |
| [`github`](github/) | Rust | GitHub CLI (`gh`) as an iii worker — typed `github::pr/issue/repo/run/workflow/release/search::*` functions plus `github::exec` argv passthrough and `github::api` for any GitHub REST endpoint. |
| [`sandbox-code-runner`](sandbox-code-runner/) | Rust | Run Node.js and Python in iii-sandbox microVMs — run code, register bus functions from working source, and tear down runtimes on demand. |
| [`openwiki`](openwiki/) | Node | Source-grounded markdown wiki for any git repository — a lead agent plans the index and writer sub-agents store cited pages via `openwiki::write-page`, with router and heuristic fallback tiers, incremental refresh from git diffs on a per-wiki cron schedule, and a browser UI + JSON API under `/openwiki`. |
| [`pdf`](pdf/) | Rust | Read PDFs locally — `pdf::classify` routes text-based versus scanned in tens of milliseconds and names the pages that still need OCR, `pdf::to-markdown` converts with headings, lists and tables intact, and `pdf::extract-items` / `::extract-regions` expose positions and the text inside a box. Ships a console page. |

Expand Down
37 changes: 37 additions & 0 deletions console/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ interface FunctionTriggerRenderer {
tryRenderRunning?(message: FunctionTriggerMessage): React.ReactNode | null
tryRenderPreview?(message: FunctionTriggerMessage): React.ReactNode | null
FunctionIdLabel?: React.ComponentType<{ functionId: string }>
redactRaw?(value: unknown): unknown
}
```

Expand All @@ -296,6 +297,42 @@ errors and everything else keep the default cards. Renderer callbacks are
fenced: a throwing `isMatch` counts as no-match, a throwing `tryRender`
degrades to an error chip, never a broken feed.

#### `redactRaw` — your card is not the only exit

However your card renders a call, the settled card also mounts a **`raw
json` tab** showing `input` and `output` verbatim, each with a copy button.
So hiding a secret inside your own rendering does not contain it: it is one
click away in the raw tab and on the clipboard.

`redactRaw` lets you declare what is secret and have the console apply it.
For a message your `isMatch` claims, the console passes the request and the
response through it **before the raw panes render and before the copy button
builds its text** (first claiming renderer that declares it wins). Keep the
knowledge of what a secret looks like in your worker — the console never
learns your patterns.

```ts
redactRaw: (value) => deepReplace(value, SECRET_PATTERN, mask)
```

Rules:

- Deep-walk the value. Secrets hide in nested arrays, in captured log lines,
in error messages, and in object **keys**, not just in the obvious field.
Preserve shape (objects, arrays, strings, numbers, booleans, `null`,
`undefined`) — the value is not always an object: `FunctionTriggerCard`
calls `redactRaw(undefined)` on every running/pending card (no `output`
yet) and hands it a bare top-level string for a double-encoded payload.
Guard against cycles so a self-referential value cannot hang the console.
- Pure and total: never mutate the argument, never do I/O, never throw. It
runs inside the card's render.
- It is fenced and **fails closed**: if it throws, the pane and the clipboard
get `[redaction failed — value withheld]`, not the raw value. A bug in your
redactor costs the raw view, never the secret.
- It is display hygiene for the chat surface, not access control: the payload
still travelled over the wire and still sits in the trace store, and a full
session export is verbatim by design.

### `host.configForms.register(configurationId, component)`

Replace the schema-generated form for one configuration entry on the Workers
Expand Down
13 changes: 12 additions & 1 deletion console/web/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
import { resultEnvelope } from '@/components/function-trigger/FunctionTriggerCard'
import {
rawRedactor,
useFunctionTriggerRenderers,
} from '@/components/function-trigger/renderer-registry'
import type { FilesystemAccessAction } from '@/components/permissions/FilesystemAccessPrompt'
import type { SessionTriggerInfo } from '@/lib/backend/triggers'
import { useConversationsCtxOptional } from '@/lib/conversations-context'
Expand Down Expand Up @@ -190,6 +194,13 @@ export function MessageList({
() => resolveRegistrations(messages, triggersById),
[messages, triggersById],
)
// Same registry `FunctionTriggerCard` uses for its own raw pane: an
// assistant-turn copy serializes each call's arguments the same way the
// call's own card does, so a worker's `redactRaw` (e.g.
// sandbox-code-runner's runtime_id) has to cover this exit too — see
// function-trigger-copy.ts.
const renderers = useFunctionTriggerRenderers()
const redactFor = (functionId: string) => rawRedactor(renderers, functionId)

// Read optionally so isolated renders (Storybook) still work without the
// ConversationsProvider; the empty state falls back to `ready` there.
Expand Down Expand Up @@ -266,7 +277,7 @@ export function MessageList({
m.role === 'assistant' ? fcallsByAssistant.get(m.id) : undefined
const copyText =
m.role === 'assistant' && (m.content || calls?.length)
? () => assistantCopyText(m.content, calls ?? [])
? () => assistantCopyText(m.content, calls ?? [], redactFor)
: undefined
// A call that directly follows another call belongs to the same
// burst of agent activity — pull it up against its predecessor so
Expand Down
127 changes: 87 additions & 40 deletions console/web/src/components/function-trigger/FunctionTriggerCard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Check, Copy, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { CopyMessageButton } from '@/components/chat/CopyMessageButton'
import {
firstNonNull,
rawRedactor,
useFunctionTriggerRenderers,
} from '@/components/function-trigger/renderer-registry'
import { AlwaysAllowButton } from '@/components/permissions/AlwaysAllowButton'
Expand Down Expand Up @@ -238,14 +239,33 @@ export function FunctionTriggerCard({
}: FunctionTriggerCardProps) {
const pending = !!message.pendingApproval
const running = !!message.running
// Registry-dispatched custom panes: injected renderers first, then the
// first-party families, then the JSON fallback below. First non-null
// wins; null falls through.
const renderers = useFunctionTriggerRenderers()
// The raw request/response as this card is allowed to show them. An
// injected renderer that claims this function id may declare `redactRaw`
// (a runtime id is a capability, so sandbox-code-runner does) — apply it
// ONCE here, then use `rawInput`/`rawOutput` everywhere below: every pane
// derives both its body and its copy text from the value it is handed, so
// redacting at the source covers the clipboard too. Card LOGIC keeps
// reading `message.*` — redaction is a display concern, not a semantic one.
// Memoized because `redactRaw` deep-walks the payload and this runs for
// every card of a claimed function id, collapsed ones included.
const { rawInput, rawOutput } = useMemo(() => {
const redact = rawRedactor(renderers, message.functionId)
return redact
? { rawInput: redact(message.input), rawOutput: redact(message.output) }
: { rawInput: message.input, rawOutput: message.output }
}, [renderers, message.functionId, message.input, message.output])
// Raw in-flight arguments tail (`_streaming`, injected by the harness
// while a call's arguments are still forming) — rendered as a live pane.
const streamingTail =
running &&
message.input &&
typeof message.input === 'object' &&
typeof (message.input as { _streaming?: unknown })._streaming === 'string'
? (message.input as { _streaming: string })._streaming
rawInput &&
typeof rawInput === 'object' &&
typeof (rawInput as { _streaming?: unknown })._streaming === 'string'
? (rawInput as { _streaming: string })._streaming
: undefined
const filesystemAccess = pending ? message.filesystemAccess : undefined
const [open, setOpen] = useState(!!defaultOpen || pending)
Expand All @@ -255,10 +275,6 @@ export function FunctionTriggerCard({
>(null)
const [submitError, setSubmitError] = useState<string | null>(null)

// Registry-dispatched custom panes: injected renderers first, then the
// first-party families, then the JSON fallback below. First non-null
// wins; null falls through.
const renderers = useFunctionTriggerRenderers()
const customPreview = firstNonNull(
renderers,
(r) => r.tryRenderPreview?.(message) ?? null,
Expand Down Expand Up @@ -313,7 +329,11 @@ export function FunctionTriggerCard({
const ran =
!isDeniedOutput(message.output) &&
(message.output !== undefined || typeof message.durationMs === 'number')
const preview = argsPreview(message.input)
// `rawInput`, not `message.input`: the collapsed header digests the request
// args inline, so it is a display exit like the raw pane and the clipboard —
// a claimed card's `redactRaw` has to cover it or a secret shows up in the
// one line that renders without anyone expanding the card.
const preview = argsPreview(rawInput)

return (
<div
Expand Down Expand Up @@ -426,15 +446,15 @@ export function FunctionTriggerCard({
{pending && customPreview ? (
<div className="border-b border-rule-2">{customPreview}</div>
) : showRequestPaneAbove ? (
<ValuePane label="request" value={message.input} />
<ValuePane label="request" value={rawInput} />
) : null}
{running && !pending ? (
streamingTail !== undefined ? (
<StreamingArgsPane text={streamingTail} />
) : hasCustomTerminal ? (
<div className="border-t border-rule-2">{customTerminal}</div>
) : (
<ValuePane label="response" value={message.output} bordered />
<ValuePane label="response" value={rawOutput} bordered />
)
) : null}
{!pending && !running ? (
Expand All @@ -450,14 +470,14 @@ export function FunctionTriggerCard({
</TabsList>
<TabsContent value="terminal">{customTerminal}</TabsContent>
<TabsContent value="json">
<ValuePane label="request" value={message.input} />
<ValuePane label="response" value={message.output} bordered />
<ValuePane label="request" value={rawInput} />
<ValuePane label="response" value={rawOutput} bordered />
</TabsContent>
</Tabs>
) : (
<>
<ValuePane label="request" value={message.input} />
<ValuePane label="response" value={message.output} bordered />
<ValuePane label="request" value={rawInput} />
<ValuePane label="response" value={rawOutput} bordered />
</>
)
) : null}
Expand Down Expand Up @@ -651,12 +671,18 @@ function StreamingArgsPane({ text }: { text: string }) {
)
}

function ValuePane({ label, value, bordered }: ValuePaneProps) {
const empty = isEmptyValue(value)
const primitive = !empty && isPrimitive(value)
const single = !empty && !primitive ? singlePrimitiveField(value) : null
const envelope =
!empty && !primitive && !single ? resultEnvelope(value) : null
/**
* The non-envelope rendering of a value: its body text, the header hints, and
* whether the body is highlighted JSON. One derivation, shared by the pane's
* body and by its copy button (`paneCopyText`) — the two can never disagree.
*/
function plainPane(value: unknown): {
body: string
hints: string[]
json: boolean
} {
const primitive = isPrimitive(value)
const single = primitive ? null : singlePrimitiveField(value)
// A string payload that is itself JSON (double-encoded): render the parsed
// structure instead of an escaped one-liner, and say so in the header.
const embedded =
Expand All @@ -665,6 +691,40 @@ function ValuePane({ label, value, bordered }: ValuePaneProps) {
: single && typeof single.value === 'string'
? parseEmbeddedJson(single.value)
: undefined
const body =
embedded !== undefined
? formatJson(embedded)
: primitive
? formatPrimitive(value)
: single
? formatPrimitive(single.value)
: formatJson(value)
return {
body,
hints: [
...(single ? [single.key] : []),
...(embedded !== undefined ? ['json string'] : []),
],
json: embedded !== undefined || !(primitive || single),
}
}

/**
* The EXACT text a pane's copy button puts on the clipboard for `value`.
* Both `ValuePane` branches route through this, so the value a pane is handed
* bounds everything that can leave it — redact the value (see `rawRedactor`)
* and the clipboard is redacted with it. A pane that renders `· empty` shows
* no copy button, so its return value is then unused.
*/
export function paneCopyText(value: unknown): string {
// The envelope pane drops text blocks that merely re-serialize `details`,
// so its copy is the whole value rather than the deduplicated rendering.
return resultEnvelope(value) ? formatJson(value) : plainPane(value).body
}

function ValuePane({ label, value, bordered }: ValuePaneProps) {
const empty = isEmptyValue(value)
const envelope = empty ? null : resultEnvelope(value)

if (empty) {
return (
Expand Down Expand Up @@ -705,7 +765,7 @@ function ValuePane({ label, value, bordered }: ValuePaneProps) {
return (
<PaneShell
label={label}
copyText={formatJson(value)}
copyText={paneCopyText(value)}
lineCount={lineCount}
bordered={bordered}
>
Expand All @@ -730,35 +790,22 @@ function ValuePane({ label, value, bordered }: ValuePaneProps) {
)
}

const body =
embedded !== undefined
? formatJson(embedded)
: primitive
? formatPrimitive(value)
: single
? formatPrimitive(single.value)
: formatJson(value)
const hints = [
...(single ? [single.key] : []),
...(embedded !== undefined ? ['json string'] : []),
]
const { body, hints, json } = plainPane(value)

return (
<PaneShell
label={label}
hints={hints}
copyText={body}
copyText={paneCopyText(value)}
lineCount={countLines(body)}
bordered={bordered}
>
{embedded !== undefined ? (
{json ? (
<JsonHighlight code={body} />
) : primitive || single ? (
) : (
<pre className={TEXT_PRE_CLS}>
<code>{body}</code>
</pre>
) : (
<JsonHighlight code={body} />
)}
</PaneShell>
)
Expand Down
Loading
Loading