mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
fix(pi): close final ownership and native UX gaps
This commit is contained in:
@@ -141,7 +141,7 @@ writeFileSync(
|
|||||||
`export { composeModelProvider } from "${PI}/packages/coding-agent/src/core/provider-composer.ts";`,
|
`export { composeModelProvider } from "${PI}/packages/coding-agent/src/core/provider-composer.ts";`,
|
||||||
`export { resolveConfigValueOrThrow } from "${PI}/packages/coding-agent/src/core/resolve-config-value.ts";`,
|
`export { resolveConfigValueOrThrow } from "${PI}/packages/coding-agent/src/core/resolve-config-value.ts";`,
|
||||||
`export { loadSkills } from "${PI}/packages/coding-agent/src/core/skills.ts";`,
|
`export { loadSkills } from "${PI}/packages/coding-agent/src/core/skills.ts";`,
|
||||||
`export { loadPromptTemplates } from "${PI}/packages/coding-agent/src/core/prompt-templates.ts";`,
|
`export { loadPromptTemplates, expandPromptTemplate } from "${PI}/packages/coding-agent/src/core/prompt-templates.ts";`,
|
||||||
`export { SessionManager } from "${PI}/packages/coding-agent/src/core/session-manager.ts";`,
|
`export { SessionManager } from "${PI}/packages/coding-agent/src/core/session-manager.ts";`,
|
||||||
`export { parseArgs } from "${PI}/packages/coding-agent/src/cli/args.ts";`,
|
`export { parseArgs } from "${PI}/packages/coding-agent/src/cli/args.ts";`,
|
||||||
`export { createAllToolDefinitions } from "${PI}/packages/coding-agent/src/core/tools/index.ts";`,
|
`export { createAllToolDefinitions } from "${PI}/packages/coding-agent/src/core/tools/index.ts";`,
|
||||||
@@ -529,33 +529,47 @@ writeFileSync(
|
|||||||
join(promptAgentDir, "prompts", "review.md"),
|
join(promptAgentDir, "prompts", "review.md"),
|
||||||
"---\ndescription: Review captured changes\nargument-hint: <range>\n---\nReview $1\n",
|
"---\ndescription: Review captured changes\nargument-hint: <range>\n---\nReview $1\n",
|
||||||
);
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(promptAgentDir, "prompts", "release notes.md"),
|
||||||
|
"This spaced filename cannot be addressed as one slash-command token.\n",
|
||||||
|
);
|
||||||
|
writeFileSync(join(promptAgentDir, "prompts", "release.v2.md"), "Release $1\n");
|
||||||
|
writeFileSync(join(promptAgentDir, "prompts", "评审.md"), "评审 $1\n");
|
||||||
writeFileSync(join(promptAgentDir, "prompts", "empty.md"), "");
|
writeFileSync(join(promptAgentDir, "prompts", "empty.md"), "");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(promptAgentDir, "prompts", "nested", "ignored.md"),
|
join(promptAgentDir, "prompts", "nested", "ignored.md"),
|
||||||
"nested",
|
"nested",
|
||||||
);
|
);
|
||||||
|
const loadedPromptTemplates = adapters.loadPromptTemplates({
|
||||||
|
cwd: promptProjectDir,
|
||||||
|
agentDir: promptAgentDir,
|
||||||
|
promptPaths: [],
|
||||||
|
includeDefaults: true,
|
||||||
|
});
|
||||||
const promptTemplateDiscovery = jsonSafeJavaScriptValue(
|
const promptTemplateDiscovery = jsonSafeJavaScriptValue(
|
||||||
adapters
|
loadedPromptTemplates.map((template) => ({
|
||||||
.loadPromptTemplates({
|
name: template.name,
|
||||||
cwd: promptProjectDir,
|
description: template.description,
|
||||||
agentDir: promptAgentDir,
|
argumentHint: template.argumentHint,
|
||||||
promptPaths: [],
|
content: template.content,
|
||||||
includeDefaults: true,
|
source: template.sourceInfo?.source,
|
||||||
})
|
scope: template.sourceInfo?.scope,
|
||||||
.map((template) => ({
|
relativeFile:
|
||||||
name: template.name,
|
template.filePath ===
|
||||||
description: template.description,
|
join(promptAgentDir, "prompts", `${template.name}.md`)
|
||||||
argumentHint: template.argumentHint,
|
? `prompts/${template.name}.md`
|
||||||
content: template.content,
|
: template.filePath,
|
||||||
source: template.sourceInfo?.source,
|
})),
|
||||||
scope: template.sourceInfo?.scope,
|
|
||||||
relativeFile:
|
|
||||||
template.filePath ===
|
|
||||||
join(promptAgentDir, "prompts", `${template.name}.md`)
|
|
||||||
? `prompts/${template.name}.md`
|
|
||||||
: template.filePath,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
const promptTemplateExpansion = [
|
||||||
|
"/review captured-range",
|
||||||
|
"/release notes",
|
||||||
|
"/release.v2 captured-range",
|
||||||
|
"/评审 变更",
|
||||||
|
].map((input) => ({
|
||||||
|
input,
|
||||||
|
result: adapters.expandPromptTemplate(input, loadedPromptTemplates),
|
||||||
|
}));
|
||||||
|
|
||||||
// File presence, including a zero-byte file, is the native activation state
|
// File presence, including a zero-byte file, is the native activation state
|
||||||
// for Pi's global instruction resources. Execute the real resource loader so
|
// for Pi's global instruction resources. Execute the real resource loader so
|
||||||
@@ -717,9 +731,7 @@ writeFileSync(
|
|||||||
[
|
[
|
||||||
JSON.stringify(capturedSession.getHeader()),
|
JSON.stringify(capturedSession.getHeader()),
|
||||||
"{not valid json",
|
"{not valid json",
|
||||||
...capturedSession
|
...capturedSession.getEntries().map((entry) => JSON.stringify(entry)),
|
||||||
.getEntries()
|
|
||||||
.map((entry) => JSON.stringify(entry)),
|
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
@@ -763,6 +775,7 @@ console.log(
|
|||||||
resolverCases,
|
resolverCases,
|
||||||
skillDiscovery,
|
skillDiscovery,
|
||||||
promptTemplateDiscovery,
|
promptTemplateDiscovery,
|
||||||
|
promptTemplateExpansion,
|
||||||
emptyInstructionFiles,
|
emptyInstructionFiles,
|
||||||
sessionDirectorySemantics,
|
sessionDirectorySemantics,
|
||||||
sessionCliSemantics,
|
sessionCliSemantics,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::database::backup::BackupEntry;
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::provider::ProviderService;
|
use crate::services::provider::ProviderService;
|
||||||
|
use crate::services::skill_deployment::PiSkillDeploymentService;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
// ─── File import/export ──────────────────────────────────────
|
// ─── File import/export ──────────────────────────────────────
|
||||||
@@ -57,7 +58,7 @@ pub async fn import_config_from_file(
|
|||||||
|
|
||||||
let import_path = filePath.clone();
|
let import_path = filePath.clone();
|
||||||
let import_result = tauri::async_runtime::spawn_blocking(move || {
|
let import_result = tauri::async_runtime::spawn_blocking(move || {
|
||||||
db.import_portable_sql(&PathBuf::from(import_path))
|
PiSkillDeploymentService::import_portable_sql(&db, &PathBuf::from(import_path))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| AppError::Message(format!("SQL import task failed: {error}")))
|
.map_err(|error| AppError::Message(format!("SQL import task failed: {error}")))
|
||||||
|
|||||||
+40
-13
@@ -300,17 +300,18 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
|||||||
|
|
||||||
/// Durable same-directory atomic replacement.
|
/// Durable same-directory atomic replacement.
|
||||||
///
|
///
|
||||||
/// Existing permissions are preserved. `new_file_mode` controls only a newly
|
/// Existing permissions are preserved when `required_file_mode` is `None`.
|
||||||
/// created Unix file (settings and other local secrets pass `0o600`). The
|
/// Sensitive callers pass an explicit mode (for example `0o600`), which is
|
||||||
/// temporary file is created exclusively, synced before replacement, and the
|
/// enforced for both new and existing Unix files before the replacement
|
||||||
/// containing directory is synced afterwards on Unix.
|
/// becomes visible. The temporary file is created exclusively, synced before
|
||||||
|
/// replacement, and the containing directory is synced afterwards on Unix.
|
||||||
pub(crate) fn atomic_write_durable(
|
pub(crate) fn atomic_write_durable(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
new_file_mode: Option<u32>,
|
required_file_mode: Option<u32>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
let _ = new_file_mode;
|
let _ = required_file_mode;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
@@ -334,7 +335,7 @@ pub(crate) fn atomic_write_durable(
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
options.mode(new_file_mode.unwrap_or(0o666));
|
options.mode(required_file_mode.unwrap_or(0o666));
|
||||||
}
|
}
|
||||||
let mut file = options
|
let mut file = options
|
||||||
.open(&tmp)
|
.open(&tmp)
|
||||||
@@ -342,18 +343,20 @@ pub(crate) fn atomic_write_durable(
|
|||||||
file.write_all(data)
|
file.write_all(data)
|
||||||
.map_err(|error| AppError::io(&tmp, error))?;
|
.map_err(|error| AppError::io(&tmp, error))?;
|
||||||
file.flush().map_err(|error| AppError::io(&tmp, error))?;
|
file.flush().map_err(|error| AppError::io(&tmp, error))?;
|
||||||
file.sync_all().map_err(|error| AppError::io(&tmp, error))?;
|
|
||||||
drop(file);
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let mode = fs::metadata(path)
|
let mode = required_file_mode.unwrap_or_else(|| {
|
||||||
.map(|metadata| metadata.permissions().mode())
|
fs::metadata(path)
|
||||||
.unwrap_or_else(|_| new_file_mode.unwrap_or(0o666));
|
.map(|metadata| metadata.permissions().mode())
|
||||||
fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))
|
.unwrap_or(0o666)
|
||||||
|
});
|
||||||
|
file.set_permissions(fs::Permissions::from_mode(mode))
|
||||||
.map_err(|error| AppError::io(&tmp, error))?;
|
.map_err(|error| AppError::io(&tmp, error))?;
|
||||||
}
|
}
|
||||||
|
file.sync_all().map_err(|error| AppError::io(&tmp, error))?;
|
||||||
|
drop(file);
|
||||||
|
|
||||||
replace_file_atomically(&tmp, path)?;
|
replace_file_atomically(&tmp, path)?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -560,6 +563,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn sensitive_atomic_write_tightens_an_existing_file_before_publish() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = temp.path().join("settings.json");
|
||||||
|
fs::write(&path, b"old").expect("seed settings");
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
|
||||||
|
.expect("make legacy settings permissive");
|
||||||
|
|
||||||
|
atomic_write_durable(&path, b"new-secret", Some(0o600)).expect("replace settings");
|
||||||
|
|
||||||
|
assert_eq!(fs::read(&path).expect("read settings"), b"new-secret");
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&path)
|
||||||
|
.expect("settings metadata")
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sort_json_keys_produces_identical_output_for_different_insertion_orders() {
|
fn sort_json_keys_produces_identical_output_for_different_insertion_orders() {
|
||||||
// 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。
|
// 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。
|
||||||
|
|||||||
@@ -285,21 +285,60 @@ fn validate_direct_instruction_content(content: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_template_slug(slug: &str) -> Result<(), AppError> {
|
fn validate_template_slug(slug: &str) -> Result<(), AppError> {
|
||||||
|
// Pinned Pi's request-capture discovers `release notes.md`, but
|
||||||
|
// expandPromptTemplate("/release notes", ...) leaves the command
|
||||||
|
// unchanged because slash-command names are one token. Keep the managed
|
||||||
|
// namespace both callable and portable across Unix and Windows.
|
||||||
|
let windows_basename = slug
|
||||||
|
.split_once('.')
|
||||||
|
.map_or(slug, |(basename, _extension)| basename);
|
||||||
|
let windows_basename = windows_basename.to_ascii_lowercase();
|
||||||
|
let windows_reserved = matches!(
|
||||||
|
windows_basename.as_str(),
|
||||||
|
"con"
|
||||||
|
| "prn"
|
||||||
|
| "aux"
|
||||||
|
| "nul"
|
||||||
|
| "com1"
|
||||||
|
| "com2"
|
||||||
|
| "com3"
|
||||||
|
| "com4"
|
||||||
|
| "com5"
|
||||||
|
| "com6"
|
||||||
|
| "com7"
|
||||||
|
| "com8"
|
||||||
|
| "com9"
|
||||||
|
| "lpt1"
|
||||||
|
| "lpt2"
|
||||||
|
| "lpt3"
|
||||||
|
| "lpt4"
|
||||||
|
| "lpt5"
|
||||||
|
| "lpt6"
|
||||||
|
| "lpt7"
|
||||||
|
| "lpt8"
|
||||||
|
| "lpt9"
|
||||||
|
);
|
||||||
let valid = !slug.is_empty()
|
let valid = !slug.is_empty()
|
||||||
&& slug.len() <= MAX_TEMPLATE_SLUG_BYTES
|
&& slug.len() <= MAX_TEMPLATE_SLUG_BYTES
|
||||||
&& slug != "."
|
&& slug != "."
|
||||||
&& slug != ".."
|
&& slug != ".."
|
||||||
&& slug.trim() == slug
|
|
||||||
&& !slug.starts_with('.')
|
&& !slug.starts_with('.')
|
||||||
&& !slug.ends_with('.')
|
&& !slug.ends_with('.')
|
||||||
&& !slug
|
&& !windows_reserved
|
||||||
.chars()
|
&& !slug.chars().any(|character| {
|
||||||
.any(|character| character.is_control() || matches!(character, '/' | '\\'));
|
character.is_control()
|
||||||
|
|| character.is_whitespace()
|
||||||
|
|| matches!(
|
||||||
|
character,
|
||||||
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
|
||||||
|
)
|
||||||
|
});
|
||||||
if valid {
|
if valid {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::InvalidInput(
|
Err(AppError::InvalidInput(
|
||||||
"Pi prompt-template slug must be one visible filename (1-128 UTF-8 bytes)".to_string(),
|
"Pi prompt-template slug must be one portable slash-command token (1-128 UTF-8 bytes)"
|
||||||
|
.to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,8 +447,16 @@ mod tests {
|
|||||||
".hidden",
|
".hidden",
|
||||||
"trailing.",
|
"trailing.",
|
||||||
" padded",
|
" padded",
|
||||||
|
"internal space",
|
||||||
|
"tab\tname",
|
||||||
"a/b",
|
"a/b",
|
||||||
r"a\b",
|
r"a\b",
|
||||||
|
"bad:name",
|
||||||
|
"bad*name",
|
||||||
|
"CON",
|
||||||
|
"con.anything",
|
||||||
|
"LPT9",
|
||||||
|
"nul.json",
|
||||||
] {
|
] {
|
||||||
assert!(validate_template_slug(slug).is_err(), "{slug:?}");
|
assert!(validate_template_slug(slug).is_err(), "{slug:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,23 @@ impl PiSkillDeploymentService {
|
|||||||
deployment_lock()
|
deployment_lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize a portable database/SSOT replacement with every Pi Skill
|
||||||
|
/// deployment mutation. Callers already hold Pi's switch boundary, so the
|
||||||
|
/// global lock order remains `Pi switch -> Skill deployment`.
|
||||||
|
pub(crate) fn coordinate_portable_import<T>(
|
||||||
|
operation: impl FnOnce() -> Result<T, AppError>,
|
||||||
|
) -> Result<T, AppError> {
|
||||||
|
let _guard = Self::operation_guard();
|
||||||
|
operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn import_portable_sql(
|
||||||
|
db: &Database,
|
||||||
|
source_path: &Path,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
Self::coordinate_portable_import(|| db.import_portable_sql(source_path))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn reconcile_skill_under_guard(
|
pub(crate) fn reconcile_skill_under_guard(
|
||||||
_guard: &MutexGuard<'static, ()>,
|
_guard: &MutexGuard<'static, ()>,
|
||||||
db: &Arc<Database>,
|
db: &Arc<Database>,
|
||||||
@@ -1126,6 +1143,44 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::app_config::SkillApps;
|
use crate::app_config::SkillApps;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn portable_import_waits_for_the_skill_ownership_boundary() {
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let db = Database::memory().expect("database");
|
||||||
|
let missing = tempfile::tempdir()
|
||||||
|
.expect("tempdir")
|
||||||
|
.path()
|
||||||
|
.join("missing.sql");
|
||||||
|
let guard = PiSkillDeploymentService::operation_guard();
|
||||||
|
let (ready_tx, ready_rx) = mpsc::channel();
|
||||||
|
let (result_tx, result_rx) = mpsc::channel();
|
||||||
|
let worker = std::thread::spawn(move || {
|
||||||
|
ready_tx.send(()).expect("signal worker ready");
|
||||||
|
let result = PiSkillDeploymentService::import_portable_sql(&db, &missing);
|
||||||
|
result_tx.send(result).expect("signal import result");
|
||||||
|
});
|
||||||
|
|
||||||
|
ready_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.expect("worker reaches import entry");
|
||||||
|
assert!(
|
||||||
|
result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
|
||||||
|
"portable import must not pass capture/publish while a Skill mutation owns the boundary"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
let result = result_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.expect("import proceeds after ownership boundary release");
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"the intentionally missing import must fail"
|
||||||
|
);
|
||||||
|
worker.join().expect("worker");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn digest_includes_hidden_files_and_rejects_symlinks() {
|
fn digest_includes_hidden_files_and_rejects_symlinks() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
@@ -310,6 +310,16 @@ pub(crate) fn apply_snapshot(
|
|||||||
db: &crate::database::Database,
|
db: &crate::database::Database,
|
||||||
db_sql: &[u8],
|
db_sql: &[u8],
|
||||||
skills_zip: &[u8],
|
skills_zip: &[u8],
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
crate::services::skill_deployment::PiSkillDeploymentService::coordinate_portable_import(|| {
|
||||||
|
apply_snapshot_under_pi_skill_guard(db, db_sql, skills_zip)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_snapshot_under_pi_skill_guard(
|
||||||
|
db: &crate::database::Database,
|
||||||
|
db_sql: &[u8],
|
||||||
|
skills_zip: &[u8],
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let sql_str = std::str::from_utf8(db_sql).map_err(|e| {
|
let sql_str = std::str::from_utf8(db_sql).map_err(|e| {
|
||||||
localized(
|
localized(
|
||||||
@@ -419,6 +429,37 @@ where
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn snapshot_application_waits_for_the_pi_skill_ownership_boundary() {
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let db = crate::database::Database::memory().expect("database");
|
||||||
|
let guard = crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
|
||||||
|
let (ready_tx, ready_rx) = mpsc::channel();
|
||||||
|
let (result_tx, result_rx) = mpsc::channel();
|
||||||
|
let worker = std::thread::spawn(move || {
|
||||||
|
ready_tx.send(()).expect("signal worker ready");
|
||||||
|
let result = apply_snapshot(&db, &[0xff], &[]);
|
||||||
|
result_tx.send(result).expect("signal snapshot result");
|
||||||
|
});
|
||||||
|
|
||||||
|
ready_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.expect("worker reaches snapshot entry");
|
||||||
|
assert!(
|
||||||
|
result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
|
||||||
|
"WebDAV/S3 snapshot application must wait for a concurrent Pi Skill mutation"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
let result = result_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.expect("snapshot proceeds after ownership boundary release");
|
||||||
|
assert!(result.is_err(), "the intentionally invalid SQL must fail");
|
||||||
|
worker.join().expect("worker");
|
||||||
|
}
|
||||||
|
|
||||||
fn artifact(sha256: &str, size: u64) -> ArtifactMeta {
|
fn artifact(sha256: &str, size: u64) -> ArtifactMeta {
|
||||||
ArtifactMeta {
|
ArtifactMeta {
|
||||||
sha256: sha256.to_string(),
|
sha256: sha256.to_string(),
|
||||||
|
|||||||
@@ -1373,6 +1373,55 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn saving_a_gateway_token_tightens_legacy_settings_permissions() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
struct EnvGuard(Option<std::ffi::OsString>);
|
||||||
|
impl Drop for EnvGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match self.0.take() {
|
||||||
|
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||||
|
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let _home = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||||
|
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||||
|
let path = AppSettings::settings_path().expect("settings path");
|
||||||
|
fs::create_dir_all(path.parent().expect("settings parent")).expect("settings directory");
|
||||||
|
fs::write(&path, b"{}").expect("legacy settings");
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
|
||||||
|
.expect("make legacy settings permissive");
|
||||||
|
|
||||||
|
let token = GatewayToken::generate();
|
||||||
|
let expected = token.expose().to_string();
|
||||||
|
let settings = AppSettings {
|
||||||
|
pi_gateway_token: Some(token),
|
||||||
|
..AppSettings::default()
|
||||||
|
};
|
||||||
|
save_settings_file(&settings).expect("save sensitive settings");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&path)
|
||||||
|
.expect("settings metadata")
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fs::read_to_string(&path)
|
||||||
|
.expect("settings body")
|
||||||
|
.contains(&expected),
|
||||||
|
"the permission assertion must exercise a persisted Pi gateway token"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn visible_apps_old_settings_default_claude_desktop_visible() {
|
fn visible_apps_old_settings_default_claude_desktop_visible() {
|
||||||
let visible: VisibleApps = serde_json::from_value(serde_json::json!({
|
let visible: VisibleApps = serde_json::from_value(serde_json::json!({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type PiPromptFileSnapshot,
|
type PiPromptFileSnapshot,
|
||||||
type PiPromptTemplate,
|
type PiPromptTemplate,
|
||||||
} from "@/lib/api/prompts";
|
} from "@/lib/api/prompts";
|
||||||
|
import { isValidPiPromptTemplateSlug } from "@/lib/piPromptSlug";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
|
||||||
const EDITABLE_FILES: Array<{
|
const EDITABLE_FILES: Array<{
|
||||||
@@ -304,13 +305,14 @@ export function PiNativePromptResources() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const slugIsValid = isValidPiPromptTemplateSlug(slug);
|
||||||
const templates = useQuery({
|
const templates = useQuery({
|
||||||
queryKey: ["pi", "promptTemplates"],
|
queryKey: ["pi", "promptTemplates"],
|
||||||
queryFn: () => promptsApi.listPiPromptTemplates(),
|
queryFn: () => promptsApi.listPiPromptTemplates(),
|
||||||
});
|
});
|
||||||
const createTemplate = useMutation({
|
const createTemplate = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
promptsApi.upsertPiPromptTemplate(slug.trim(), "missing", content),
|
promptsApi.upsertPiPromptTemplate(slug, "missing", content),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setSlug("");
|
setSlug("");
|
||||||
setContent("");
|
setContent("");
|
||||||
@@ -383,7 +385,13 @@ export function PiNativePromptResources() {
|
|||||||
value={slug}
|
value={slug}
|
||||||
onChange={(event) => setSlug(event.target.value)}
|
onChange={(event) => setSlug(event.target.value)}
|
||||||
placeholder={t("pi.prompts.templateSlug")}
|
placeholder={t("pi.prompts.templateSlug")}
|
||||||
|
aria-invalid={slug.length > 0 && !slugIsValid}
|
||||||
/>
|
/>
|
||||||
|
{slug.length > 0 && !slugIsValid && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">
|
||||||
|
{t("pi.prompts.templateSlugInvalid")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Textarea
|
<Textarea
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(event) => setContent(event.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
@@ -396,7 +404,7 @@ export function PiNativePromptResources() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => createTemplate.mutate()}
|
onClick={() => createTemplate.mutate()}
|
||||||
disabled={!slug.trim() || createTemplate.isPending}
|
disabled={!slugIsValid || createTemplate.isPending}
|
||||||
>
|
>
|
||||||
{createTemplate.isPending && (
|
{createTemplate.isPending && (
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -994,6 +994,7 @@
|
|||||||
"templatesDescription": "Each prompts/<slug>.md file is invoked by Pi as /<slug>.",
|
"templatesDescription": "Each prompts/<slug>.md file is invoked by Pi as /<slug>.",
|
||||||
"newTemplate": "New prompt template",
|
"newTemplate": "New prompt template",
|
||||||
"templateSlug": "Template slug (for example: review)",
|
"templateSlug": "Template slug (for example: review)",
|
||||||
|
"templateSlugInvalid": "Use one command token without spaces or filename-reserved characters.",
|
||||||
"templateContent": "Template Markdown",
|
"templateContent": "Template Markdown",
|
||||||
"createTemplate": "Create template",
|
"createTemplate": "Create template",
|
||||||
"templateCreated": "Pi prompt template created",
|
"templateCreated": "Pi prompt template created",
|
||||||
|
|||||||
@@ -994,6 +994,7 @@
|
|||||||
"templatesDescription": "prompts/<slug>.md の各ファイルは、Pi で /<slug> として呼び出せます。",
|
"templatesDescription": "prompts/<slug>.md の各ファイルは、Pi で /<slug> として呼び出せます。",
|
||||||
"newTemplate": "新しいプロンプトテンプレート",
|
"newTemplate": "新しいプロンプトテンプレート",
|
||||||
"templateSlug": "テンプレートスラッグ(例: review)",
|
"templateSlug": "テンプレートスラッグ(例: review)",
|
||||||
|
"templateSlugInvalid": "空白やファイル名の予約文字を含まない、1 つのコマンド名を入力してください。",
|
||||||
"templateContent": "テンプレート Markdown",
|
"templateContent": "テンプレート Markdown",
|
||||||
"createTemplate": "テンプレートを作成",
|
"createTemplate": "テンプレートを作成",
|
||||||
"templateCreated": "Pi プロンプトテンプレートを作成しました",
|
"templateCreated": "Pi プロンプトテンプレートを作成しました",
|
||||||
|
|||||||
@@ -995,6 +995,7 @@
|
|||||||
"templatesDescription": "每個 prompts/<slug>.md 檔案都可在 Pi 中透過 /<slug> 呼叫。",
|
"templatesDescription": "每個 prompts/<slug>.md 檔案都可在 Pi 中透過 /<slug> 呼叫。",
|
||||||
"newTemplate": "新增提示範本",
|
"newTemplate": "新增提示範本",
|
||||||
"templateSlug": "範本識別碼(例如:review)",
|
"templateSlug": "範本識別碼(例如:review)",
|
||||||
|
"templateSlugInvalid": "請輸入不含空格或檔名保留字元的單一指令識別碼。",
|
||||||
"templateContent": "範本 Markdown",
|
"templateContent": "範本 Markdown",
|
||||||
"createTemplate": "建立範本",
|
"createTemplate": "建立範本",
|
||||||
"templateCreated": "Pi 提示範本已建立",
|
"templateCreated": "Pi 提示範本已建立",
|
||||||
|
|||||||
@@ -994,6 +994,7 @@
|
|||||||
"templatesDescription": "每个 prompts/<标识>.md 文件都可在 Pi 中通过 /<标识> 调用。",
|
"templatesDescription": "每个 prompts/<标识>.md 文件都可在 Pi 中通过 /<标识> 调用。",
|
||||||
"newTemplate": "新建提示词模板",
|
"newTemplate": "新建提示词模板",
|
||||||
"templateSlug": "模板标识(例如 review)",
|
"templateSlug": "模板标识(例如 review)",
|
||||||
|
"templateSlugInvalid": "请输入不含空格或文件名保留字符的单个命令标识。",
|
||||||
"templateContent": "模板 Markdown 内容",
|
"templateContent": "模板 Markdown 内容",
|
||||||
"createTemplate": "创建模板",
|
"createTemplate": "创建模板",
|
||||||
"templateCreated": "Pi 提示词模板已创建",
|
"templateCreated": "Pi 提示词模板已创建",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const WINDOWS_RESERVED_BASENAME =
|
||||||
|
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
||||||
|
const PORTABLE_FILENAME_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/u;
|
||||||
|
|
||||||
|
export function isValidPiPromptTemplateSlug(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.length > 0 &&
|
||||||
|
new TextEncoder().encode(value).byteLength <= 128 &&
|
||||||
|
value !== "." &&
|
||||||
|
value !== ".." &&
|
||||||
|
!value.startsWith(".") &&
|
||||||
|
!value.endsWith(".") &&
|
||||||
|
!/\s/u.test(value) &&
|
||||||
|
!PORTABLE_FILENAME_FORBIDDEN.test(value) &&
|
||||||
|
!WINDOWS_RESERVED_BASENAME.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -113,6 +113,29 @@ describe("PiNativePromptResources", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not offer prompt-template names that Pi cannot invoke portably", async () => {
|
||||||
|
renderResources();
|
||||||
|
|
||||||
|
const slug = screen.getByPlaceholderText("pi.prompts.templateSlug");
|
||||||
|
const create = screen.getByRole("button", {
|
||||||
|
name: "pi.prompts.createTemplate",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const invalid of ["release notes", "bad:name", "CON"]) {
|
||||||
|
fireEvent.change(slug, { target: { value: invalid } });
|
||||||
|
expect(create).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByText("pi.prompts.templateSlugInvalid"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.change(slug, { target: { value: "release.v2" } });
|
||||||
|
expect(create).toBeEnabled();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("pi.prompts.templateSlugInvalid"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("requires confirmation before creating the dangerous SYSTEM override", async () => {
|
it("requires confirmation before creating the dangerous SYSTEM override", async () => {
|
||||||
renderResources();
|
renderResources();
|
||||||
|
|
||||||
@@ -122,9 +145,7 @@ describe("PiNativePromptResources", () => {
|
|||||||
fireEvent.change(instructionEditors[0], {
|
fireEvent.change(instructionEditors[0], {
|
||||||
target: { value: "replace the system prompt" },
|
target: { value: "replace the system prompt" },
|
||||||
});
|
});
|
||||||
fireEvent.click(
|
fireEvent.click(screen.getAllByRole("button", { name: "common.save" })[0]);
|
||||||
screen.getAllByRole("button", { name: "common.save" })[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(promptsApi.replacePiPromptFile).not.toHaveBeenCalled();
|
expect(promptsApi.replacePiPromptFile).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isValidPiPromptTemplateSlug } from "@/lib/piPromptSlug";
|
||||||
|
|
||||||
|
describe("isValidPiPromptTemplateSlug", () => {
|
||||||
|
it("accepts callable Unicode and dotted slugs", () => {
|
||||||
|
expect(isValidPiPromptTemplateSlug("review-pr")).toBe(true);
|
||||||
|
expect(isValidPiPromptTemplateSlug("release.v2")).toBe(true);
|
||||||
|
expect(isValidPiPromptTemplateSlug("评审")).toBe(true);
|
||||||
|
expect(isValidPiPromptTemplateSlug("SYSTEM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects whitespace, portable filename hazards, and Windows device names", () => {
|
||||||
|
for (const slug of [
|
||||||
|
"release notes",
|
||||||
|
"tab\tname",
|
||||||
|
"bad:name",
|
||||||
|
"bad*name",
|
||||||
|
"CON",
|
||||||
|
"con.anything",
|
||||||
|
"LPT9",
|
||||||
|
"nul.json",
|
||||||
|
]) {
|
||||||
|
expect(isValidPiPromptTemplateSlug(slug), slug).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("measures the contract limit in UTF-8 bytes", () => {
|
||||||
|
expect(isValidPiPromptTemplateSlug("a".repeat(128))).toBe(true);
|
||||||
|
expect(isValidPiPromptTemplateSlug("a".repeat(129))).toBe(false);
|
||||||
|
expect(isValidPiPromptTemplateSlug("评".repeat(42))).toBe(true);
|
||||||
|
expect(isValidPiPromptTemplateSlug("评".repeat(43))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user