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
11 changes: 8 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,15 @@ jobs:
files: |
artifacts/*

- name: Sync Cargo.toml version
- name: Sync Cargo.toml and Cargo.lock version
run: |
sed -i 's/^version = ".*"/version = "${{ needs.version.outputs.version }}"/' Cargo.toml
VERSION="${{ needs.version.outputs.version }}"
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml
# Cargo.lock records the crate's own version as well, and every
# dependency's: bump only the line after `name = "switchboard-cli"`.
# Without this the committed lock trails Cargo.toml by one release.
sed -i '/^name = "switchboard-cli"$/{n;s/^version = ".*"/version = "'"${VERSION}"'"/}' Cargo.lock
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Cargo.toml
git add Cargo.toml Cargo.lock
git diff --cached --quiet || (git commit -m "chore: release v${{ needs.version.outputs.version }}" && git push)
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ switchboard auth login --renown --app-name my-agent # what the vault shows bes
switchboard auth status # Signing: on as my-agent (did:key:z…) acting for 0x…
```

From then on `docs apply`, `docs mutate`, `docs link`, `docs annotate` and `docs unlink` send actions through the typed `execute` mutation with a `context.signer`: ECDSA P-256 over `"\x19Signed Operation:\n" + len + timestamp + did + sha256(scope+type+input) + prevStateHash`, exactly what `@renown/sdk` produces, so the reactor stores the action untouched and rejects a tampered one (`InvalidSignatureError`). `docs create` and the model-namespaced paths that build actions server-side remain server-signed.
From then on `docs apply`, `docs mutate`, `docs link`, `docs annotate` and `docs unlink` send actions through the typed `execute` mutation with a `context.signer`: ECDSA P-256 over `"\x19Signed Operation:\n" + len + timestamp + did + actionHash + prevStateHash`, where `actionHash` is the reactor's v2 hash — `"v2:" + base64url(sha256(canonicalJson([…])))` over the document, branch, scope, type, action id, timestamp, input and signer — exactly what `@powerhousedao/shared`'s `signActionV2` produces. The reactor stores the action untouched and refuses a tampered one. A v2 signature is bound to one action in one document: a document created by a 6.2.3-dev.24 or later reactor refuses anything else (`SCHEME_BELOW_POLICY`), and an older Switchboard, which checks only the ECDSA, accepts it too. `docs create` and the model-namespaced paths that build actions server-side remain server-signed.

Per-invocation overrides: `SWITCHBOARD_APP_NAME` (label this run differently), `SWITCHBOARD_IDENTITY_DIR` (another `.ph` directory), `SWITCHBOARD_UNSIGNED=1` (write unsigned on purpose). A configured identity that cannot be loaded is an error, never a silent downgrade to unsigned. The signature proves the key; the key↔address binding is the Renown credential (`.renown.json`, 7-day validity — `auth status` warns when it has expired; renew with `ph login`).

Expand Down
21 changes: 14 additions & 7 deletions src/cli/docs.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use anyhow::{Result, bail};
use anyhow::{Context, Result, bail};
use clap::Subcommand;
use colored::Colorize;
use dialoguer::{Input, Select};
Expand Down Expand Up @@ -1819,17 +1819,22 @@ async fn apply(
// mutateDocument resolver doesn't inject it (unlike model-specific resolvers).
let actions = stamp_actions(actions);

let resolved_id = helpers::resolve_doc(&client, id)
.await
.unwrap_or_else(|_| id.to_string());
let resolved = helpers::resolve_doc(&client, id).await;
let resolved_id = resolved.as_deref().unwrap_or(id).to_string();

let job_id = match identity {
// Signed: every action carries our signature and goes through the
// typed `executeAsync`, the wire form that accepts `context.signer`.
// The reactor stores a pre-signed action untouched, so the operation
// is attributed to this key — not to the Switchboard's identity.
Some((identity, app_name)) => {
let actions = sign_actions(actions, &identity, &app_name)?;
// A v2 signature covers the document id, and the reactor resolves
// a slug before it verifies — signing the raw fallback would sign
// a preimage the reactor never builds.
let document_id = resolved.with_context(|| {
format!("cannot sign for '{id}': it did not resolve to a document")
})?;
let actions = sign_actions(actions, &identity, &app_name, &document_id, "main")?;
let vars = serde_json::json!({
"documentIdentifier": resolved_id,
"actions": actions,
Expand Down Expand Up @@ -2062,7 +2067,7 @@ async fn relationship(
let mut action = relationship_action(op, &source_id, &target_id, rel_type, metadata.as_ref());
let signed_as = match identity {
Some((identity, app_name)) => {
identity.sign_action(&mut action, &app_name)?;
identity.sign_action(&mut action, &app_name, &source_id, "main")?;
Some(app_name)
}
None => None,
Expand Down Expand Up @@ -2128,6 +2133,8 @@ pub fn sign_actions(
actions: Value,
identity: &crate::identity::Identity,
app_name: &str,
document_id: &str,
branch: &str,
) -> Result<Value> {
let Value::Array(mut arr) = actions else {
bail!("Actions must be a JSON array");
Expand All @@ -2139,7 +2146,7 @@ pub fn sign_actions(
map.entry("scope")
.or_insert_with(|| Value::String("global".to_string()));
}
identity.sign_action(action, app_name)?;
identity.sign_action(action, app_name, document_id, branch)?;
}
Ok(Value::Array(arr))
}
Expand Down
128 changes: 82 additions & 46 deletions src/cli/drives.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ use colored::Colorize;
use dialoguer::{Confirm, Input};
use serde_json::Value;

use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL};

use crate::cli::helpers;
use crate::output::{self, OutputFormat, print_json, print_table};

Expand Down Expand Up @@ -242,7 +240,7 @@ async fn get(

if files > 0 || folders > 0 {
println!();
print_drive_tree(nodes, None, "");
print_drive_tree(nodes);
}
}
}
Expand Down Expand Up @@ -467,10 +465,20 @@ async fn find_drive_uuid_by_name(
.map(String::from)
}

/// Print drive contents as a hybrid tree (folders) + table (documents) view.
/// Folders are rendered with tree connectors; documents inside each folder are
/// displayed as a formatted table indented under the folder.
fn print_drive_tree(nodes: &[Value], parent: Option<&str>, indent: &str) {
/// Print drive contents as a folder/document tree, one line per node.
fn print_drive_tree(nodes: &[Value]) {
for line in drive_tree_lines(nodes, None, "") {
println!("{line}");
}
}

/// The tree under `parent`: folders as `📁 name/`, documents as
/// `📄 name type id`, one line each. Type and id are dimmed so the name
/// reads first, and are not padded into columns — document names run to
/// ~100 characters, so aligning them would make a folder wider than the table
/// this replaces. The id is kept whole and last, where it is easy to copy.
/// Within a folder, documents come before sub-folders.
fn drive_tree_lines(nodes: &[Value], parent: Option<&str>, indent: &str) -> Vec<String> {
let children: Vec<&Value> = nodes
.iter()
.filter(|n| {
Expand All @@ -481,54 +489,49 @@ fn print_drive_tree(nodes: &[Value], parent: Option<&str>, indent: &str) {
}
})
.collect();

let folders: Vec<&Value> = children
let files = children
.iter()
.filter(|n| n["kind"].as_str() == Some("folder"))
.copied()
.collect();

let files: Vec<&Value> = children
.filter(|n| n["kind"].as_str() == Some("file"));
let folders = children
.iter()
.filter(|n| n["kind"].as_str() == Some("file"))
.copied()
.collect();

// Render documents as an indented table
if !files.is_empty() {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Disabled);
table.set_header(["ID", "Name", "Type"]);
for f in &files {
table.add_row(vec![
f["id"].as_str().unwrap_or("-"),
f["name"].as_str().unwrap_or("-"),
f["documentType"].as_str().unwrap_or("-"),
]);
}
for line in table.to_string().lines() {
println!("{indent}{line}");
}
}
.filter(|n| n["kind"].as_str() == Some("folder"));
let ordered: Vec<&Value> = files.chain(folders).copied().collect();

// Render sub-folders as tree entries
for (i, folder) in folders.iter().enumerate() {
let is_last = i == folders.len() - 1;
let mut lines = Vec::new();
for (i, node) in ordered.iter().enumerate() {
let is_last = i + 1 == ordered.len();
let connector = if is_last {
"\u{2514}\u{2500}\u{2500} "
} else {
"\u{251c}\u{2500}\u{2500} "
};
let child_indent = if is_last { " " } else { "\u{2502} " };

let name = folder["name"].as_str().unwrap_or("-");
let id = folder["id"].as_str().unwrap_or("");

println!("{indent}{connector}\u{1f4c1} {name}/");
print_drive_tree(nodes, Some(id), &format!("{indent}{child_indent}"));
let name = node["name"]
.as_str()
.filter(|s| !s.is_empty())
.unwrap_or("-");
if node["kind"].as_str() == Some("folder") {
lines.push(format!("{indent}{connector}\u{1f4c1} {name}/"));
// A folder with no id would match the root's children and recurse
// forever; list the folder, not its (unaddressable) contents.
if let Some(id) = node["id"].as_str().filter(|s| !s.is_empty()) {
let child_indent = if is_last { " " } else { "\u{2502} " };
lines.extend(drive_tree_lines(
nodes,
Some(id),
&format!("{indent}{child_indent}"),
));
}
} else {
let doc_type = node["documentType"].as_str().unwrap_or("-");
let id = node["id"].as_str().unwrap_or("-");
lines.push(format!(
"{indent}{connector}\u{1f4c4} {name} {} {}",
doc_type.dimmed(),
id.dimmed()
));
}
}
lines
}

async fn check(id: &str, format: OutputFormat, profile_name: Option<&str>) -> Result<()> {
Expand Down Expand Up @@ -672,3 +675,36 @@ async fn fix(

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn drive_tree_is_one_line_per_node_with_name_type_and_id() {
colored::control::set_override(false);
let nodes = serde_json::json!([
{ "id": "f1", "kind": "file", "name": "readme", "documentType": "bai/note", "parentFolder": null },
{ "id": "k", "kind": "folder", "name": "knowledge", "parentFolder": "" },
{ "id": "f2", "kind": "file", "name": "m6", "documentType": "bai/moc", "parentFolder": "k" },
{ "id": "n", "kind": "folder", "name": "notes", "parentFolder": "k" },
{ "id": "f3", "kind": "file", "name": "", "documentType": "bai/moc", "parentFolder": "k" },
{ "id": "f4", "kind": "file", "name": "a long note name", "documentType": "bai/knowledge-note", "parentFolder": "n" },
// No id: listed, never recursed into (it would match the root).
{ "id": "", "kind": "folder", "name": "ghost", "parentFolder": null }
]);
let lines = drive_tree_lines(nodes.as_array().unwrap(), None, "");
assert_eq!(
lines,
[
"├── 📄 readme bai/note f1",
"├── 📁 knowledge/",
"│ ├── 📄 m6 bai/moc f2",
"│ ├── 📄 - bai/moc f3",
"│ └── 📁 notes/",
"│ └── 📄 a long note name bai/knowledge-note f4",
"└── 📁 ghost/",
]
);
}
}
2 changes: 1 addition & 1 deletion src/cli/guide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ SIGNED WRITES
Without an identity the Switchboard signs your actions with ITS identity and
attributes them to whoever ran `ph login` on the server. With
`auth login --renown`, docs apply / mutate / link / unlink are signed
client-side (ECDSA P-256, the @renown/sdk scheme) and stored untouched, so
client-side (ECDSA P-256, the reactor's v2 scheme) and stored untouched, so
the vault sees your key, your address and your --app-name. Overrides per run:
SWITCHBOARD_APP_NAME, SWITCHBOARD_IDENTITY_DIR, SWITCHBOARD_UNSIGNED=1.

Expand Down
2 changes: 1 addition & 1 deletion src/cli/mutate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ pub async fn run(args: MutateArgs, format: OutputFormat, profile_name: Option<&s
"timestampUtcMs": crate::cli::docs::iso_now(),
"input": input_value,
});
identity.sign_action(&mut action, app_name)?;
identity.sign_action(&mut action, app_name, &resolved_doc_id, "main")?;
eprintln!(
"Running: {}",
format!(
Expand Down
Loading
Loading