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
10 changes: 8 additions & 2 deletions docs/en/docs/operate/context-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,18 @@ client returns either `PreparedContext` or `PreparedContextWithReuse`; narrow th
In process, use `runtime.context.for_scope(scope_id).prepare(request)`. Its `validate_reuse` operation checks the
retained proof without retrieval or embedding; embedded applications supply their own current authorization context.

The first reuse profile supports a single Scope and selected Atomic Memory only, without context references or code.
It records selected current revisions, lifecycle versions, and complete bounded historical dependencies. Exact
The reuse profile supports a single Scope, selected Atomic Memory, and code evidence (`include_code: true`), without
context references. For Atomic Memory it records selected current revisions, lifecycle versions, and complete bounded historical dependencies. Exact
historical ancestors of a merge remain valid evidence when their owning domain permits them. Revised or forgotten
roots, revoked access, changed reference policy, expired bindings, and ineligible Sources stop reuse.
Other selected families are explicitly rejected for this option; ordinary preparation remains supported.

For code evidence it records each cited file's repository path and SHA-256 digest, with the index fingerprint for the
record. Validation reads only the cited files from the Scope's current worktree, with the same path and exclusion rules
as indexing. A changed, removed, or no-longer-indexable cited file, or an unavailable repository, stops reuse. An edit to
a file that was not cited, or a rebuilt index, does not. Code files and Atomic Memory dependencies share the dependency
limit.

Bindings are authenticated and encrypted, expire after five minutes, and are limited to 64 dependencies and eight
ancestry levels. They do not grant access. Server bindings use the existing signing-secret material with a separate
cryptographic purpose and persistent Server identity; processes sharing that deployment must share the secret.
Expand Down
5 changes: 5 additions & 0 deletions docs/en/docs/operate/deploy-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ translates its requests to the Chat or Responses route and forwards them. Set `P
(`PC_GATEWAY_CORE_BASE`) injects only with `PC_GATEWAY_INJECTION=on`. An empty or failed preparation sends the request
without injection.

Set `PC_GATEWAY_INJECTION_CODE=on` to include code evidence from the Scope's code index in the prepared context; it is
off by default and requires a configured repository for the Scope. When a replayed value no longer passes the check
because a cited source changed (for example, the agent edited a cited file), the gateway discards that conversation's
replay and prepares once more for the request instead of returning the withhold.

### Gateway instruction

`PC_GATEWAY_STEERING_FILE` names a file with a short, fixed instruction that the gateway adds to every request on the
Expand Down
8 changes: 6 additions & 2 deletions docs/zh/docs/operate/context-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ Python Client 返回 `PreparedContext` 或 `PreparedContextWithReuse`,读取
进程内沿用 `runtime.context.for_scope(scope_id).prepare(request)`;
`validate_reuse` 检查保留的证明,不做检索或 Embedding。嵌入式应用自行提供当前可信授权上下文。

首个复用 profile 支持单 Scope、实际选中的 Atomic Memory,不支持 context references 或代码。
它记录最终选中的当前 revision、生命周期版本和完整有界历史依赖。合并结果的旧祖先在领域规则允许时仍可作为证据。
复用 profile 支持单 Scope、实际选中的 Atomic Memory 和代码证据(`include_code: true`),不支持 context references。
对 Atomic Memory,它记录最终选中的当前 revision、生命周期版本和完整有界历史依赖。合并结果的旧祖先在领域规则允许时仍可作为证据。
根记忆被修订或遗忘、访问撤销、引用范围变化、绑定过期、Source 不再符合资格,都会停止旧值复用。
请求复用时实际选中了其他 family,会明确拒绝;普通 prepare 保持原支持范围。

对代码证据,它记录每个被引用文件的仓库路径和 SHA-256 摘要,并附带索引指纹以备查。校验时只按与建索引相同的路径和排除
规则,从 Scope 当前工作区读取被引用的文件。被引用文件被修改、删除或不再可索引,或仓库不可用,都会停止复用;修改未被
引用的文件或重建索引不会。代码文件与 Atomic Memory 依赖共用依赖上限。

绑定经过认证加密,期限五分钟,最多 64 个依赖、八层祖先。绑定不是访问权限。
Server 用既有签名秘密派生独立用途的密钥,并绑定持久 Server 身份;同部署的多个进程需要共享该秘密。
进程内 Runtime 默认使用临时密钥。没有新增 prompt 或响应缓存表。
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/docs/operate/deploy-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ Rust 可执行程序、联合 launcher、网关配置、私有通道认证、私
`PC_GATEWAY_INJECTION_RESPONSES=off` 只关闭 Responses 入口的注入。独立运行的网关(`PC_GATEWAY_CORE_BASE`)只有
设置 `PC_GATEWAY_INJECTION=on` 时才注入。准备结果为空或准备失败时,请求照常发送,不注入内容。

设置 `PC_GATEWAY_INJECTION_CODE=on` 后,准备的上下文同时包含 Scope 代码索引中的代码证据;该选项默认关闭,并要求 Scope
已配置代码仓库。重放的内容因被引用的来源变化(例如 Agent 修改了被引用的文件)而不再通过检查时,网关丢弃该会话的重放,
为该请求重新准备一次,而不是把 withhold 返回给客户端。

### 网关固定指令

`PC_GATEWAY_STEERING_FILE` 指定一个文件,其中是一段简短的固定指令,网关把它加入 Responses 入口
Expand Down
3 changes: 2 additions & 1 deletion gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ PC_GATEWAY_UPSTREAM_API_KEY_CHAT=... \
# requests that belong to a conversation (X-PowerContext-Conversation-ID or a recognized client session header)
# are injected by default; standalone is off by default.
# PC_GATEWAY_INJECTION=on injects every request, PC_GATEWAY_INJECTION=off disables injection, and
# PC_GATEWAY_INJECTION_RESPONSES=off keeps the Responses route uninjected.
# PC_GATEWAY_INJECTION_RESPONSES=off keeps the Responses route uninjected, and PC_GATEWAY_INJECTION_CODE=on adds code
# evidence from the Scope's code index (off by default).
# Optional: PC_GATEWAY_STEERING_FILE (a fixed operator instruction added to Responses requests after the client's
# base prompt; read and validated at startup), PC_GATEWAY_SEDIMENT=on (capture after release),
# PC_GATEWAY_DEGRADE=passthrough (no-memory passthrough when Core is unreachable; never applies
Expand Down
2 changes: 2 additions & 0 deletions gateway/crates/pc-gateway-app/src/ingress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ async fn ingress_model(
state.inject_responses,
conversation.is_some(),
),
include_code: state.inject_code,
conversation_id: conversation,
sediment: state.sediment,
prepared_contexts: Vec::new(),
Expand Down Expand Up @@ -758,6 +759,7 @@ mod tests {
degrade: pc_gateway_core::DegradePolicy::Pause,
injection: Injection::Off,
inject_responses: false,
inject_code: false,
sediment: false,
steering: None,
default_scope: "test".into(),
Expand Down
6 changes: 6 additions & 0 deletions gateway/crates/pc-gateway-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub struct AppState {
/// Responses 入口是否注入:默认跟随 injection,
/// `PC_GATEWAY_INJECTION_RESPONSES=off` 可单独关闭。
pub inject_responses: bool,
/// 注入时同时请求代码证据:`PC_GATEWAY_INJECTION_CODE=on`(默认关)。
pub inject_code: bool,
/// 过检后同步沉淀对话(默认关)。
pub sediment: bool,
/// 运维配置的固定指令(`PC_GATEWAY_STEERING_FILE`),只插入 Responses 请求;未配置时为空。
Expand Down Expand Up @@ -156,6 +158,10 @@ async fn main() {
std::env::var("PC_GATEWAY_INJECTION").ok().as_deref(),
std::env::var("PC_GATEWAY_CORE_UDS").is_ok(),
),
inject_code: matches!(
std::env::var("PC_GATEWAY_INJECTION_CODE").as_deref(),
Ok("on")
),
inject_responses: !matches!(
std::env::var("PC_GATEWAY_INJECTION_RESPONSES").as_deref(),
Ok("off")
Expand Down
183 changes: 110 additions & 73 deletions gateway/crates/pc-gateway-core/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
use crate::lifecycle::Execution;
use crate::replay_cache::{message_anchor, PreparedBlock, ReplayCache, MAX_REPLAY_MS};
use crate::transcript::turn_transcript;

/// Core 的 before_send 在复用证明的依赖已变化或证明已失效时给出的 withhold 原因。
const CONTEXT_REFRESH_REQUIRED: &str = "context-refresh-required";
use crate::{GateClient, GateError};
use crate::{ModelSendState, RequestControl};
use pc_gateway_evidence::EvidenceSink;
Expand Down Expand Up @@ -62,6 +65,8 @@ pub struct ManagedCall {
pub headers: Vec<(String, String)>,
pub degrade_policy: DegradePolicy,
pub inject_context: bool,
/// 注入时同时请求代码证据(默认关)。
pub include_code: bool,
/// 宿主提供的会话 ID(`X-PowerContext-Conversation-ID`)。只有带它的请求才在会话内重放准备结果。
pub conversation_id: Option<String>,
pub sediment: bool,
Expand Down Expand Up @@ -161,89 +166,120 @@ where
// 重新渲染,否则 prepare 召回的私密记忆会在未经任何检查的情况下外发。
let pristine_request = call.request.clone();

if call.inject_context {
let anchor = message_anchor(&pristine_request);
self.enrich(&mut call, anchor, renderer.message_list_key(), evidence)
.await;
}
let anchor = call
.inject_context
.then(|| message_anchor(&pristine_request))
.flatten();
let mut refreshed = false;
let (payload, admission) = loop {
if call.inject_context {
self.enrich(&mut call, anchor, renderer.message_list_key(), evidence)
.await;
}

let payload = match renderer.render(&call.request, None) {
Ok(p) => p,
Err(e) => {
return ManagedOutcome::Blocked {
reason_codes: vec![format!("render_failed:{e}")],
let payload = match renderer.render(&call.request, None) {
Ok(p) => p,
Err(e) => {
return ManagedOutcome::Blocked {
reason_codes: vec![format!("render_failed:{e}")],
}
}
}
};
// Measure the actual wire body after prepare/replay, steering and JSON escaping.
if payload.bytes.len() > self.max_input_bytes {
return ManagedOutcome::Blocked {
reason_codes: vec!["request_payload_too_large".into()],
};
}
let payload_body = match String::from_utf8(payload.bytes.clone().to_vec()) {
Ok(text) => text,
Err(_) => {
// Measure every rendered body, including a refreshed prepare result.
if payload.bytes.len() > self.max_input_bytes {
return ManagedOutcome::Blocked {
reason_codes: vec!["invalid_payload_encoding".into()],
}
}
};
let model_payload = ModelJsonPayload::new(
content_headers(&call.headers, payload.content_type),
payload_body,
);
let check = ContextCheckRequest::BeforeSend {
scope_id: call.scope_id.clone(),
target_profile: call.target_profile.clone(),
payload: model_payload,
prepared_contexts: call.prepared_contexts.clone(),
};

let admission = match self.gate.check(check).await {
Err(e) => {
// 降级仅适用于 Core「不可用」;Malformed(可达但响应异常)一律暂停。
return match (e, call.degrade_policy) {
(GateError::GateUnavailable(_), DegradePolicy::PassthroughWithoutMemory) => {
// P1-3:降级时丢弃已注入的载荷,用 pristine 请求重新渲染。
let mut degraded_call = ManagedCall {
scope_id: call.scope_id.clone(),
target_profile: call.target_profile.clone(),
request: pristine_request,
target_url: call.target_url.clone(),
headers: call.headers.clone(),
degrade_policy: DegradePolicy::Pause,
inject_context: false,
conversation_id: None,
sediment: false,
prepared_contexts: Vec::new(),
};
degraded_call.request.stream = call.request.stream;
self.degraded_send(&degraded_call, renderer, evidence, execution)
.await
}
(e, _) => ManagedOutcome::GatePaused(classify_gate_error(e)),
reason_codes: vec!["request_payload_too_large".into()],
};
}
Ok(result) => match result.decision {
CheckDecision::Allow => match validate_allow(&result, CheckPhase::BeforeSend) {
Ok((binding, deadline)) => Admission {
binding,
valid_until_ms: deadline,
},
Err(outcome) => return outcome,
},
CheckDecision::Block => {
let payload_body = match String::from_utf8(payload.bytes.clone().to_vec()) {
Ok(text) => text,
Err(_) => {
return ManagedOutcome::Blocked {
reason_codes: result.reason_codes,
reason_codes: vec!["invalid_payload_encoding".into()],
}
}
CheckDecision::Withhold => {
return ManagedOutcome::Withheld {
reason_codes: result.reason_codes,
}
};
let model_payload = ModelJsonPayload::new(
content_headers(&call.headers, payload.content_type),
payload_body,
);
let check = ContextCheckRequest::BeforeSend {
scope_id: call.scope_id.clone(),
target_profile: call.target_profile.clone(),
payload: model_payload,
prepared_contexts: call.prepared_contexts.clone(),
};

let admission = match self.gate.check(check).await {
Err(e) => {
// 降级仅适用于 Core「不可用」;Malformed(可达但响应异常)一律暂停。
return match (e, call.degrade_policy) {
(
GateError::GateUnavailable(_),
DegradePolicy::PassthroughWithoutMemory,
) => {
// P1-3:降级时丢弃已注入的载荷,用 pristine 请求重新渲染。
let mut degraded_call = ManagedCall {
scope_id: call.scope_id.clone(),
target_profile: call.target_profile.clone(),
request: pristine_request.clone(),
target_url: call.target_url.clone(),
headers: call.headers.clone(),
degrade_policy: DegradePolicy::Pause,
inject_context: false,
include_code: false,
conversation_id: None,
sediment: false,
prepared_contexts: Vec::new(),
};
degraded_call.request.stream = call.request.stream;
self.degraded_send(&degraded_call, renderer, evidence, execution)
.await
}
(e, _) => ManagedOutcome::GatePaused(classify_gate_error(e)),
};
}
},
Ok(result) => match result.decision {
CheckDecision::Allow => match validate_allow(&result, CheckPhase::BeforeSend) {
Ok((binding, deadline)) => Admission {
binding,
valid_until_ms: deadline,
},
Err(outcome) => return outcome,
},
CheckDecision::Block => {
return ManagedOutcome::Blocked {
reason_codes: result.reason_codes,
}
}
// 复用证明的依赖已变化(例如 Agent 改了被引用的代码文件):丢弃该会话的重放,从注入前的
// 请求重新准备一次再送检,不把可恢复的过期交给客户端。每个请求最多刷新一次。
CheckDecision::Withhold
if !refreshed
&& !call.prepared_contexts.is_empty()
&& result
.reason_codes
.iter()
.any(|reason| reason == CONTEXT_REFRESH_REQUIRED) =>
{
refreshed = true;
if let Some((conversation, anchor)) =
call.conversation_id.as_deref().zip(anchor)
{
self.replays.forget(&call.scope_id, conversation, anchor);
}
call.request = pristine_request.clone();
call.prepared_contexts.clear();
continue;
}
CheckDecision::Withhold => {
return ManagedOutcome::Withheld {
reason_codes: result.reason_codes,
}
}
},
};
break (payload, admission);
};

// P1-4:发送阶段。每次尝试前验有效期;确定性失败后重新走 check 拿新准入。
Expand Down Expand Up @@ -494,6 +530,7 @@ where
query,
max_bytes: 8192,
include_reuse: true,
include_code: call.include_code,
// P2 review:限定 memory-only(#2313 reuse 只支持 atomic-memory),
// 防止 Experience/Topic 加入后 check 拒绝导致注入静默失效。
assembly: Some(pc_gateway_types::ContextAssembly::memory_only()),
Expand Down
7 changes: 7 additions & 0 deletions gateway/crates/pc-gateway-core/src/replay_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ impl ReplayCache {
.map(|e| e.prepared.clone())
}

/// 丢弃一个锚点的结果,例如它的复用证明已不能通过 check;下一次请求重新准备。
pub(crate) fn forget(&self, scope_id: &str, conversation_id: &str, anchor: u64) {
lock(&self.entries).retain(|e| {
!(e.scope_id == scope_id && e.conversation_id == conversation_id && e.anchor == anchor)
});
}

/// 写入一个锚点的结果(put-if-absent):已有同一锚点时保留先写入的值并返回它,保证同一锚点只有
/// 一种注入内容。同一会话的不同锚点并存、不互相替换,上一回合迟到完成的准备不会挤掉当前回合。
pub(crate) fn put_if_absent(
Expand Down
Loading
Loading