fix(pi): close final ownership and native UX gaps

This commit is contained in:
SaladDay
2026-08-03 11:49:56 +00:00
parent 469d6e0f59
commit afd58e31d8
15 changed files with 364 additions and 48 deletions
+37 -24
View File
@@ -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,
+2 -1
View File
@@ -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
View File
@@ -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() {
// 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。 // 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。
+52 -5
View File
@@ -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");
+41
View File
@@ -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(),
+49
View File
@@ -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" />
+1
View File
@@ -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",
+1
View File
@@ -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 プロンプトテンプレートを作成しました",
+1
View File
@@ -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 提示範本已建立",
+1
View File
@@ -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 提示词模板已创建",
+17
View File
@@ -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(
+33
View File
@@ -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);
});
});