You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/17 05:27:41 UTC
(maka) branch rust-preview updated: feat(host): capture and stream root-scoped diagnostics
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/rust-preview by this push:
new 805233a4f3 feat(host): capture and stream root-scoped diagnostics
805233a4f3 is described below
commit 805233a4f359f10f56079ed1ece8d6872af78e8c
Author: Wang <m4...@gmail.com>
AuthorDate: Sat Oct 17 13:27:24 2026 +0800
feat(host): capture and stream root-scoped diagnostics
Capture dedicated Host stderr before detached startup and expose bounded raw-byte reads and cancellable subscriptions through the CLI and explicitly granted native service. Add no-session Diagnostics access in the default product and verify the real terminal, public Client, cursor recovery, and cancellation boundaries.
Generated-by: Codex
---
packages/plugin-sdk/README.md | 2 +
packages/plugin-sdk/README.zh-CN.md | 2 +
packages/plugin-sdk/src/contracts.ts | 232 +++++++++++++++
packages/plugin-sdk/src/generated/runtime.ts | 27 ++
packages/plugin-sdk/src/native.ts | 10 +
platform/host-js/tests/application_services.rs | 2 +
.../tests/application_services/host_logs.rs | 310 +++++++++++++++++++
platform/host-runtime/README.md | 2 +
platform/host-runtime/README.zh-CN.md | 2 +
platform/host-runtime/src/application.rs | 2 +
platform/host-runtime/src/application/logs.rs | 148 ++++++++++
platform/host-runtime/src/lib.rs | 1 +
platform/host-runtime/src/logs.rs | 327 +++++++++++++++++++++
platform/host-runtime/src/logs/filesystem.rs | 240 +++++++++++++++
platform/host-runtime/src/logs/tests.rs | 283 ++++++++++++++++++
platform/js-runtime/src/plugin/engine.rs | 6 +-
.../tests/integration/plugin_vm/captured.rs | 23 ++
platform/process/src/detached.rs | 8 +-
platform/process/src/detached/windows.rs | 31 +-
plugins/agent/agent-screen/actions.ts | 18 +-
plugins/agent/agent-screen/desktop.ts | 2 +
plugins/agent/agent-screen/diagnostics.ts | 213 ++++++++------
plugins/agent/agent-screen/events.ts | 11 +-
plugins/agent/agent-screen/frame.ts | 22 +-
plugins/agent/agent-screen/header-view.ts | 4 +-
plugins/agent/agent-screen/host-logs.ts | 264 +++++++++++++++++
plugins/agent/agent-screen/index.ts | 2 +
plugins/agent/agent-screen/locale.ts | 14 +
plugins/agent/agent-screen/package.ts | 2 +
plugins/agent/agent-screen/results.ts | 13 +
plugins/agent/agent-screen/state.ts | 3 +
plugins/agent/agent-screen/tui.ts | 2 +
plugins/application/gateway/package.ts | 1 +
.../application/host-logs/package.ts | 26 +-
plugins/shared/locale-messages.ts | 24 ++
products/maka/bundle/package-inputs.json | 1 +
products/maka/bundle/src/composition.ts | 11 +
products/maka/bundle/tests/task-workbench.test.mjs | 257 +++++++++++++++-
products/maka/cli/README.md | 2 +
products/maka/cli/README.zh-CN.md | 2 +
products/maka/cli/notices.json | 40 +--
products/maka/cli/src/application/serve.rs | 12 +-
products/maka/cli/src/args.rs | 5 +-
products/maka/cli/src/deployment/activation.rs | 10 +-
products/maka/cli/src/deployment/diagnostics.rs | 13 +-
products/maka/cli/src/deployment/entry.rs | 6 +-
products/maka/cli/src/deployment/local.rs | 3 +
products/maka/cli/src/deployment/logs.rs | 236 ++++-----------
products/maka/cli/src/deployment/service/macos.rs | 20 +-
products/maka/cli/src/logging.rs | 83 ++++++
products/maka/cli/src/serve.rs | 5 +
products/maka/cli/src/windows.rs | 70 -----
products/maka/cli/tests/integration/candidate.rs | 1 +
.../cli/tests/integration/candidate/startup.rs | 321 ++++++++++++++++++++
.../tests/integration/terminal/bundled_product.rs | 1 +
.../terminal/bundled_product/host_logs.rs | 132 +++++++++
tools/schema/src/lib.rs | 6 +
57 files changed, 3107 insertions(+), 409 deletions(-)
diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index 7e91b254d5..585d7ef56c 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -44,4 +44,6 @@ Generation uses [maka-schema](../../tools/schema/README.md) and does not build t
Components have synchronous callbacks that propose a frame and actions. Terminal and desktop use their own scene types. Native controls own editor buffers, selection and IME; capture resources through their declared contracts. Host services receive request-scoped imports and attachments, without ambient Node APIs.
+Guest realms provide `TextEncoder`, `TextDecoder`, `atob`, `URL` and `URLSearchParams` for pure text and byte conversion. Decode Base64 to bytes with `Uint8Array.from(atob(value), c => c.charCodeAt(0))`; these APIs grant no filesystem or network access.
+
TypeScript checks authoring mistakes. The runtime separately validates values, contract compatibility, resource slots and permissions. See the [plugin guide](../../plugins/README.md) and [runnable starter](../../skills/maka-plugin-authoring/references/quickstart.md).
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index c16b616b62..9bd0613b87 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -44,4 +44,6 @@ npm --workspace @maka-agent/plugin-sdk run typecheck
组件的同步回调提出帧和动作,终端与桌面使用各自场景类型。原生控件管理编辑器缓冲区、选择和 IME;应通过声明的契约捕获资源。Host 服务接收请求作用域内的导入与附件,不提供环境级 Node API。
+客体运行时提供 `TextEncoder`、`TextDecoder`、`atob`、`URL` 和 `URLSearchParams` 进行纯文本与字节转换。可用 `Uint8Array.from(atob(value), c => c.charCodeAt(0))` 将 Base64 解码成字节;这些 API 不授予文件或网络访问权限。
+
TypeScript 帮助发现编写错误,运行时另外校验值、契约兼容性、资源槽位和权限。参阅[插件指南](../../plugins/README.zh-CN.md)和[可运行示例](../../skills/maka-plugin-authoring/references/quickstart.md)。
diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts
index ec772c5f43..ce17455fd4 100644
--- a/packages/plugin-sdk/src/contracts.ts
+++ b/packages/plugin-sdk/src/contracts.ts
@@ -53028,6 +53028,238 @@ export const contracts = {
},
},
},
+ hostLogs: {
+ name: 'maka.host.Logs',
+ version: 1,
+ transport: 'remote',
+ interface: {
+ kind: 'service',
+ methods: {
+ read: {
+ mode: 'query',
+ input: {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'HostLogRequest',
+ type: 'object',
+ properties: {
+ position: {
+ $ref: '#/$defs/HostLogPosition',
+ },
+ },
+ additionalProperties: false,
+ required: ['position'],
+ $defs: {
+ HostLogPosition: {
+ oneOf: [
+ {
+ type: 'object',
+ properties: {
+ kind: {
+ type: 'string',
+ const: 'tail',
+ },
+ },
+ required: ['kind'],
+ additionalProperties: false,
+ },
+ {
+ type: 'object',
+ properties: {
+ cursor: {
+ $ref: '#/$defs/HostLogCursor',
+ },
+ kind: {
+ type: 'string',
+ const: 'after',
+ },
+ },
+ additionalProperties: false,
+ required: ['kind', 'cursor'],
+ },
+ ],
+ },
+ HostLogCursor: {
+ type: 'string',
+ maxLength: 512,
+ },
+ },
+ },
+ output: {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'HostLogPage',
+ oneOf: [
+ {
+ type: 'object',
+ properties: {
+ kind: {
+ type: 'string',
+ const: 'missing',
+ },
+ },
+ required: ['kind'],
+ additionalProperties: false,
+ },
+ {
+ type: 'object',
+ properties: {
+ cursor: {
+ $ref: '#/$defs/HostLogCursor',
+ },
+ bytes: {
+ type: 'string',
+ },
+ reset: {
+ anyOf: [
+ {
+ $ref: '#/$defs/HostLogReset',
+ },
+ {
+ type: 'null',
+ },
+ ],
+ },
+ truncated: {
+ type: 'boolean',
+ },
+ hasMore: {
+ type: 'boolean',
+ },
+ kind: {
+ type: 'string',
+ const: 'data',
+ },
+ },
+ additionalProperties: false,
+ required: ['kind', 'cursor', 'bytes', 'truncated', 'hasMore'],
+ },
+ ],
+ $defs: {
+ HostLogCursor: {
+ type: 'string',
+ maxLength: 512,
+ },
+ HostLogReset: {
+ type: 'string',
+ enum: ['replaced', 'truncated'],
+ },
+ },
+ },
+ resources: {},
+ },
+ watch: {
+ mode: 'subscription',
+ input: {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'HostLogRequest',
+ type: 'object',
+ properties: {
+ position: {
+ $ref: '#/$defs/HostLogPosition',
+ },
+ },
+ additionalProperties: false,
+ required: ['position'],
+ $defs: {
+ HostLogPosition: {
+ oneOf: [
+ {
+ type: 'object',
+ properties: {
+ kind: {
+ type: 'string',
+ const: 'tail',
+ },
+ },
+ required: ['kind'],
+ additionalProperties: false,
+ },
+ {
+ type: 'object',
+ properties: {
+ cursor: {
+ $ref: '#/$defs/HostLogCursor',
+ },
+ kind: {
+ type: 'string',
+ const: 'after',
+ },
+ },
+ additionalProperties: false,
+ required: ['kind', 'cursor'],
+ },
+ ],
+ },
+ HostLogCursor: {
+ type: 'string',
+ maxLength: 512,
+ },
+ },
+ },
+ output: {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'HostLogPage',
+ oneOf: [
+ {
+ type: 'object',
+ properties: {
+ kind: {
+ type: 'string',
+ const: 'missing',
+ },
+ },
+ required: ['kind'],
+ additionalProperties: false,
+ },
+ {
+ type: 'object',
+ properties: {
+ cursor: {
+ $ref: '#/$defs/HostLogCursor',
+ },
+ bytes: {
+ type: 'string',
+ },
+ reset: {
+ anyOf: [
+ {
+ $ref: '#/$defs/HostLogReset',
+ },
+ {
+ type: 'null',
+ },
+ ],
+ },
+ truncated: {
+ type: 'boolean',
+ },
+ hasMore: {
+ type: 'boolean',
+ },
+ kind: {
+ type: 'string',
+ const: 'data',
+ },
+ },
+ additionalProperties: false,
+ required: ['kind', 'cursor', 'bytes', 'truncated', 'hasMore'],
+ },
+ ],
+ $defs: {
+ HostLogCursor: {
+ type: 'string',
+ maxLength: 512,
+ },
+ HostLogReset: {
+ type: 'string',
+ enum: ['replaced', 'truncated'],
+ },
+ },
+ },
+ resources: {},
+ },
+ },
+ },
+ },
workspaceComputer: {
name: 'maka.workspace.Computer',
version: 1,
diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts
index 2142f678bd..8e2d776df4 100644
--- a/packages/plugin-sdk/src/generated/runtime.ts
+++ b/packages/plugin-sdk/src/generated/runtime.ts
@@ -20,6 +20,8 @@
// Generated from Rust runtime schemas. Run npm run schema:generate -w @maka-agent/plugin-sdk.
export type RuntimeProtocol =
+ | HostLogRequest
+ | HostLogPage
| UiAuthoringCommand
| UiAuthoringClaim
| UiAuthoringState
@@ -341,6 +343,28 @@ export type RuntimeProtocol =
| ResourceMetadata
| MarkdownTextInput
| MarkdownTextResult;
+export type HostLogPosition =
+ | {
+ kind: 'tail';
+ }
+ | {
+ cursor: HostLogCursor;
+ kind: 'after';
+ };
+export type HostLogCursor = string;
+export type HostLogPage =
+ | {
+ kind: 'missing';
+ }
+ | {
+ cursor: HostLogCursor;
+ bytes: string;
+ reset?: HostLogReset | null;
+ truncated: boolean;
+ hasMore: boolean;
+ kind: 'data';
+ };
+export type HostLogReset = 'replaced' | 'truncated';
export type UiAuthoringCommand =
| {
method: 'instances';
@@ -5081,6 +5105,9 @@ export type MarkdownTextResult =
kind: 'too_large';
};
+export interface HostLogRequest {
+ position: HostLogPosition;
+}
export interface DeploymentRef {
stateDomain: string;
deployment: string;
diff --git a/packages/plugin-sdk/src/native.ts b/packages/plugin-sdk/src/native.ts
index c224c95a17..dbdbf50287 100644
--- a/packages/plugin-sdk/src/native.ts
+++ b/packages/plugin-sdk/src/native.ts
@@ -402,6 +402,11 @@ export const nativeImplementations = /* @__PURE__ */ ((): {
configuration: { type: 'object'; properties: {}; required: []; additionalProperties: false };
exports: { Authentication: typeof contracts.settingsAuthentication };
};
+ hostLogs: {
+ implementation: string;
+ configuration: { type: 'object'; properties: {}; required: []; additionalProperties: false };
+ exports: { HostLogs: typeof contracts.hostLogs };
+ };
settingsSetup: {
implementation: string;
configuration: { type: 'object'; properties: {}; required: []; additionalProperties: false };
@@ -787,6 +792,11 @@ export const nativeImplementations = /* @__PURE__ */ ((): {
configuration: { type: 'object', properties: {}, required: [], additionalProperties: false },
exports: { Authentication: contracts.settingsAuthentication },
},
+ hostLogs: {
+ implementation: 'maka.host.logs',
+ configuration: { type: 'object', properties: {}, required: [], additionalProperties: false },
+ exports: { HostLogs: contracts.hostLogs },
+ },
settingsSetup: {
implementation: 'maka.settings.setup',
configuration: { type: 'object', properties: {}, required: [], additionalProperties: false },
diff --git a/platform/host-js/tests/application_services.rs b/platform/host-js/tests/application_services.rs
index 76f73b1c16..6ad83762ce 100644
--- a/platform/host-js/tests/application_services.rs
+++ b/platform/host-js/tests/application_services.rs
@@ -21,6 +21,8 @@
mod caller;
#[path = "application_services/execution_basis.rs"]
mod execution_basis;
+#[path = "application_services/host_logs.rs"]
+mod host_logs;
#[path = "application_services/management.rs"]
mod management;
#[path = "application_services/native_context.rs"]
diff --git a/platform/host-js/tests/application_services/host_logs.rs b/platform/host-js/tests/application_services/host_logs.rs
new file mode 100644
index 0000000000..666fadfc94
--- /dev/null
+++ b/platform/host-js/tests/application_services/host_logs.rs
@@ -0,0 +1,310 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_event_log::{
+ EventLog,
+ root::{RootNamespaces, RootOwner},
+};
+use maka_host_runtime::{
+ application::{
+ host::{Client, Host},
+ transport::{Grant as EndpointGrant, HostEndpoint, wire::Reply},
+ },
+ ipc::LocalListener,
+};
+use std::io::Write;
+
+fn sources() -> (Input, Vec<Package>) {
+ let contract = maka_host_runtime::application::logs::contract();
+ let logs = package(
+ "test.host.logs",
+ EntryDeclaration {
+ scene: None,
+ implementation: Implementation::Native {
+ implementation: "maka.host.logs".into(),
+ },
+ configuration: json!({"type":"object","additionalProperties":false}),
+ imports: BTreeMap::new(),
+ exports: BTreeMap::from([(
+ "HostLogs".into(),
+ Export {
+ contract: contract.clone(),
+ availability: Availability::Required,
+ },
+ )]),
+ },
+ None,
+ );
+ let consumer = package(
+ "test.host.logs.client",
+ EntryDeclaration {
+ scene: None,
+ implementation: Implementation::JavaScript {
+ module: "host.mjs".into(),
+ realm: Realm::Dedicated,
+ },
+ configuration: json!({"type":"object"}),
+ exports: BTreeMap::new(),
+ imports: BTreeMap::from([(
+ "logs".into(),
+ Import {
+ contract,
+ cardinality: Cardinality::One,
+ },
+ )]),
+ },
+ Some("export function activate(){return {}; }".into()),
+ );
+ let broken = package(
+ "test.host.logs.unrelated",
+ EntryDeclaration {
+ scene: None,
+ implementation: Implementation::JavaScript {
+ module: "host.mjs".into(),
+ realm: Realm::Dedicated,
+ },
+ configuration: json!({"type":"object"}),
+ imports: BTreeMap::new(),
+ exports: BTreeMap::new(),
+ },
+ Some(
+ "export function activate(){throw new Error('unrelated business initializer failed');}"
+ .into(),
+ ),
+ );
+ let mut client = node(&consumer, 2, true);
+ client.bindings = BTreeMap::from([(
+ "logs".into(),
+ vec![Target::Instance {
+ path: vec!["logs".into()],
+ export: "HostLogs".into(),
+ }],
+ )]);
+ (
+ Input {
+ environment: Environment {
+ location: ExecutionLocation::Host,
+ presentation: None,
+ },
+ instances: BTreeMap::from([
+ ("logs".into(), node(&logs, 1, true)),
+ ("client".into(), client),
+ ("unrelated".into(), node(&broken, 3, true)),
+ ]),
+ recovery: None,
+ launch: None,
+ },
+ vec![logs, consumer, broken],
+ )
+}
+
+#[tokio::test]
+async fn fixed_root_logs_public_endpoint_survives_unrelated_initialization_failure() {
+ tokio::time::timeout(Duration::from_secs(30), async {
+ let directory = tempfile::tempdir().unwrap();
+ let namespaces = RootNamespaces {
+ ownership: directory.path().join("owners"),
+ control: directory.path().join("control"),
+ };
+ let owner =
+ Arc::new(RootOwner::create(&directory.path().join("root"), &namespaces).unwrap());
+ let mut capture = maka_host_runtime::logs::Source::from_owner(&owner)
+ .unwrap()
+ .open_capture()
+ .unwrap();
+ capture.write_all(b"before\n").unwrap();
+ capture.flush().unwrap();
+ let store = Arc::new(EventLog::for_root(owner).await.unwrap());
+ let (input, packages) = sources();
+ let source = store
+ .commit_application(Uuid::new_v4(), 0, input, vec![], packages)
+ .await
+ .unwrap();
+ let host = Host::open(
+ store.clone(),
+ source.deployment,
+ NativeRegistry::default(),
+ Some(maka_host_js::factory(Limits::default())),
+ [("logs", vec!["read", "watch"]), ("readOnly", vec!["read"])]
+ .into_iter()
+ .map(|(name, methods)| {
+ (
+ name.into(),
+ HostEndpoint {
+ consumer: id(2),
+ import: "logs".into(),
+ grant: EndpointGrant {
+ methods: methods.into_iter().map(str::to_owned).collect(),
+ recovery: false,
+ },
+ },
+ )
+ })
+ .collect(),
+ vec![],
+ )
+ .await
+ .unwrap();
+ let ipc = directory.path().join("ipc");
+ maka_event_log::root::private_directory(&ipc).unwrap();
+ let address = ipc.join("host.sock");
+ let listener = LocalListener::bind(&address).unwrap();
+ let stop = CancellationToken::new();
+ let served = {
+ let host = host.clone();
+ let stop = stop.clone();
+ tokio::spawn(async move { host.serve_local(listener, stop).await })
+ };
+ let mut client = Client::connect(&address, Some(host.epoch())).await.unwrap();
+ let startup_error = client.catalogue().startup_error().map(str::to_owned);
+ let observed = async {
+ let read = query(&client, "logs", json!({"position":{"kind":"tail"}})).await?;
+ let path_error = query(
+ &client,
+ "logs",
+ json!({"position":{"kind":"tail"},"path":"/ungranted/private.log"}),
+ )
+ .await
+ .err();
+ let mut denied = client
+ .call(
+ "readOnly",
+ "watch".into(),
+ json!({"position":{"kind":"tail"}}),
+ Attachments::new(),
+ )
+ .await?;
+ // Method grants reject before resource reservation; start consumes
+ // that terminal Aborted reply and returns its reason directly.
+ let denied_reason = denied
+ .start()
+ .await
+ .err()
+ .ok_or("ungranted watch unexpectedly reserved resources")?;
+ denied.close().await?;
+ if !denied.cleanup_confirmed() {
+ return Err("rejected watch cleanup unconfirmed".into());
+ }
+ let mut watch = subscribe(&client, json!({"position":{"kind":"tail"}})).await?;
+ let initial = item(&mut watch).await?;
+ capture
+ .write_all(b"after\n")
+ .map_err(|error| error.to_string())?;
+ capture.flush().map_err(|error| error.to_string())?;
+ let appended = item(&mut watch).await?;
+ watch.close().await?;
+ if !watch.cleanup_confirmed() {
+ return Err("watch cancellation did not confirm physical cleanup".into());
+ }
+ let cursor = appended["cursor"].clone();
+ client.close().await?;
+ client = Client::connect(&address, Some(host.epoch())).await?;
+ capture
+ .write_all(b"reconnect\n")
+ .map_err(|error| error.to_string())?;
+ capture.flush().map_err(|error| error.to_string())?;
+ let mut resumed = subscribe(
+ &client,
+ json!({"position":{"kind":"after","cursor":cursor}}),
+ )
+ .await?;
+ let reconnected = item(&mut resumed).await?;
+ resumed.close().await?;
+ if !resumed.cleanup_confirmed() {
+ return Err("reconnected watch cleanup unconfirmed".into());
+ }
+ Ok::<_, String>((
+ read,
+ initial,
+ appended,
+ reconnected,
+ denied_reason,
+ path_error,
+ ))
+ }
+ .await;
+ client.close().await.unwrap();
+ stop.cancel();
+ served.await.unwrap().unwrap();
+ host.shutdown().await.unwrap();
+ drop(host);
+ let store = Arc::try_unwrap(store)
+ .unwrap_or_else(|_| panic!("Host cleanup retained the original EventLog"));
+ store.close().await.unwrap();
+ let (read, initial, appended, reconnected, denied_reason, path_error) = observed.expect(
+ "explicitly granted public HostLogs must read and follow the original Root capture",
+ );
+ assert_eq!(read["kind"], "data");
+ assert_eq!(read["bytes"], "YmVmb3JlCg==");
+ assert_eq!(read["reset"], Value::Null);
+ assert_eq!(read["truncated"], false);
+ assert_eq!(read["hasMore"], false);
+ assert_eq!(initial["bytes"], "YmVmb3JlCg==");
+ assert_eq!(appended["bytes"], "YWZ0ZXIK");
+ assert_eq!(reconnected["bytes"], "cmVjb25uZWN0Cg==");
+ assert_eq!(reconnected["reset"], Value::Null);
+ assert_eq!(denied_reason, "Application method is not granted");
+ assert!(path_error.is_some(), "request fields cannot choose a path");
+ assert!(
+ startup_error
+ .unwrap()
+ .contains("unrelated business initializer failed")
+ );
+ })
+ .await
+ .unwrap();
+}
+
+async fn query(client: &Client, endpoint: &str, input: Value) -> Result<Value> {
+ let mut call = client
+ .call(endpoint, "read".into(), input, Attachments::new())
+ .await?;
+ call.start().await?;
+ let reply = call.receive().await?;
+ call.close().await?;
+ match reply {
+ Reply::Finished { result, cleanup } => {
+ cleanup?;
+ result
+ }
+ other => Err(format!("unexpected log query reply: {other:?}")),
+ }
+}
+async fn subscribe(
+ client: &Client,
+ input: Value,
+) -> Result<maka_host_runtime::application::host::LocalCall> {
+ let mut call = client
+ .call("logs", "watch".into(), input, Attachments::new())
+ .await?;
+ call.start().await?;
+ let reply = call.receive().await?;
+ if !matches!(reply, Reply::Subscribed) {
+ call.close().await?;
+ return Err(format!("unexpected log subscription reply: {reply:?}"));
+ }
+ Ok(call)
+}
+async fn item(call: &mut maka_host_runtime::application::host::LocalCall) -> Result<Value> {
+ match call.pull().await? {
+ Reply::Item { value } => Ok(value),
+ other => Err(format!("unexpected log subscription item: {other:?}")),
+ }
+}
diff --git a/platform/host-runtime/README.md b/platform/host-runtime/README.md
index f8a7309532..35484b0fc2 100644
--- a/platform/host-runtime/README.md
+++ b/platform/host-runtime/README.md
@@ -27,4 +27,6 @@ A committed source is desired configuration. It becomes effective only after its
Persistence comes from the owned EventLog. Stable plugin data belongs to the Root, deployment and instance; an activation epoch identifies execution, not a new data owner.
+The built-in `maka.host.logs` provider derives its fixed log source from that same Root owner. Its `HostLogs` export (`maka.host.Logs`) offers bounded raw-byte `read` queries and cancellable `watch` subscriptions. Compositions must bind and explicitly grant these methods; registration grants no access, and requests cannot select another Root or file. See the generated SDK contract for page and cursor fields.
+
[Source](src/lib.rs) · [Cargo manifest](Cargo.toml)
diff --git a/platform/host-runtime/README.zh-CN.md b/platform/host-runtime/README.zh-CN.md
index 47218d8b36..676f10435c 100644
--- a/platform/host-runtime/README.zh-CN.md
+++ b/platform/host-runtime/README.zh-CN.md
@@ -27,4 +27,6 @@
持久化由已持有的 EventLog 提供。插件数据归属于 Root、部署和实例;激活 epoch 标识一次执行,不会创建新的数据所有者。
+内置 `maka.host.logs` provider 从同一个 Root 所有者派生日志源。其 `HostLogs` 导出(`maka.host.Logs`)提供有界原始字节 `read` 查询和可取消的 `watch` 订阅。组合必须绑定并显式授权这些方法;注册本身不授予访问权,请求也不能选择其他 Root 或文件。页和游标字段见生成的 SDK 契约。
+
[源码](src/lib.rs) · [Cargo 配置](Cargo.toml)
diff --git a/platform/host-runtime/src/application.rs b/platform/host-runtime/src/application.rs
index ac06b19ece..0eab42def9 100644
--- a/platform/host-runtime/src/application.rs
+++ b/platform/host-runtime/src/application.rs
@@ -23,6 +23,7 @@ pub mod backend;
mod caller;
mod context;
pub mod host;
+pub mod logs;
pub mod management;
mod native_context;
pub mod operations;
@@ -730,6 +731,7 @@ impl Application {
}
operations::provide(&mut native)?;
management::provide(&mut native)?;
+ logs::provide(&mut native)?;
let (source, packages, plan, snapshot) = loaded.into_parts();
Self::start_owned(
source.input,
diff --git a/platform/host-runtime/src/application/logs.rs b/platform/host-runtime/src/application/logs.rs
new file mode 100644
index 0000000000..16f4fc7261
--- /dev/null
+++ b/platform/host-runtime/src/application/logs.rs
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Explicitly granted reads of the original Root's fixed raw stderr capture.
+
+use super::*;
+use crate::logs::{HostLogPage, HostLogRequest, Source};
+use maka_plugin_core::contract::{Contract, Interface, Method, MethodMode, Transport};
+use serde::Deserialize;
+
+pub const HOST_LOGS: &str = "maka.host.logs";
+
+struct Logs(Source);
+impl Handler for Logs {
+ fn call(
+ &self,
+ method: String,
+ input: Value,
+ context: Context,
+ ) -> BoxFuture<'static, Result<Value>> {
+ let source = self.0.clone();
+ Box::pin(async move {
+ let request: HostLogRequest =
+ serde_json::from_value(input).map_err(|error| error.to_string())?;
+ match method.as_str() {
+ "read" => page(
+ source
+ .read(request)
+ .await
+ .map_err(|error| error.to_string())?,
+ ),
+ "watch" => {
+ source
+ .watch(request, context.scope().cancellation.clone(), |value| {
+ let emitted = page(value).map(|value| context.emit(value));
+ async move {
+ emitted
+ .map_err(std::io::Error::other)?
+ .await
+ .map_err(std::io::Error::other)
+ }
+ })
+ .await
+ .map_err(|error| error.to_string())?;
+ Ok(Value::Null)
+ }
+ _ => Err("unknown Host logs method".into()),
+ }
+ })
+ }
+}
+fn page(page: HostLogPage) -> Result<Value> {
+ let value = serde_json::to_value(page).map_err(|error| error.to_string())?;
+ maka_plugin_core::json::check_delivery(&value, 48 * 1024)?;
+ Ok(value)
+}
+
+pub(super) fn provide(native: &mut NativeRegistry) -> Result<()> {
+ #[derive(Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Configuration {}
+ native.insert(
+ HOST_LOGS.into(),
+ Arc::new(|context, configuration| {
+ Box::pin(async move {
+ let exports = (|| {
+ serde_json::from_value::<Configuration>(configuration)
+ .map_err(|error| error.to_string())?;
+ let persistence = context
+ .persistence()
+ .ok_or("Host logs require the original persisted Root")?;
+ let root = persistence
+ .store()
+ .owned_root()
+ .map_err(|error| error.to_string())?;
+ let source = Source::from_owner(&root).map_err(|error| error.to_string())?;
+ Ok(BTreeMap::from([(
+ "HostLogs".into(),
+ Published {
+ contract: contract(),
+ value: Arc::new(Logs(source)) as Arc<dyn Handler>,
+ },
+ )]))
+ })();
+ // Every read/follow belongs to its original Application invocation.
+ NativeActivation {
+ exports,
+ owner: Arc::new(()),
+ }
+ })
+ }),
+ )
+}
+
+pub fn contract() -> Contract {
+ let input = serde_json::to_value(schemars::schema_for!(HostLogRequest))
+ .expect("Host log request schema");
+ let output =
+ serde_json::to_value(schemars::schema_for!(HostLogPage)).expect("Host log page schema");
+ Contract {
+ name: "maka.host.Logs".into(),
+ version: 1,
+ transport: Transport::Remote,
+ interface: Interface::Service {
+ methods: [
+ ("read", MethodMode::Query),
+ ("watch", MethodMode::Subscription),
+ ]
+ .into_iter()
+ .map(|(name, mode)| {
+ (
+ name.into(),
+ Method {
+ mode,
+ input: input.clone(),
+ output: output.clone(),
+ progress: None,
+ resources: BTreeMap::new(),
+ },
+ )
+ })
+ .collect(),
+ },
+ }
+}
+pub fn native() -> maka_plugin_core::package::NativeDefinition {
+ maka_plugin_core::package::NativeDefinition::new(
+ HOST_LOGS,
+ serde_json::json!({"type":"object","properties":{},"required":[],"additionalProperties":false}),
+ [("HostLogs", contract())],
+ )
+}
diff --git a/platform/host-runtime/src/lib.rs b/platform/host-runtime/src/lib.rs
index 147389f96c..8e8432d6fe 100644
--- a/platform/host-runtime/src/lib.rs
+++ b/platform/host-runtime/src/lib.rs
@@ -21,6 +21,7 @@ pub mod application;
mod directories;
pub mod http;
pub mod ipc;
+pub mod logs;
pub mod recovery;
pub mod sandbox;
pub mod shell;
diff --git a/platform/host-runtime/src/logs.rs b/platform/host-runtime/src/logs.rs
new file mode 100644
index 0000000000..dc2fdc15bd
--- /dev/null
+++ b/platform/host-runtime/src/logs.rs
@@ -0,0 +1,327 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Fixed-Root raw stderr bytes. Readers never retain the Root writer lease.
+//!
+//! Writers append; replacement and observed shrink reset cursors. Copytruncate
+//! followed by regrowth between reads and hostile file-ID reuse are not detected.
+//! Cancellation settles any started blocking read; a stalled filesystem syscall
+//! has no promised wall-clock cancellation deadline.
+
+mod filesystem;
+#[cfg(test)]
+mod tests;
+
+use base64::{
+ Engine,
+ engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
+};
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use schemars::JsonSchema;
+use serde::{Deserialize, Deserializer, Serialize};
+use std::{
+ fmt,
+ fs::File,
+ future::Future,
+ io,
+ path::{Path, PathBuf},
+ str::FromStr,
+ time::Duration,
+};
+use tokio_util::sync::CancellationToken;
+
+const RAW_LIMIT: usize = 32 * 1024;
+const CURSOR_LIMIT: usize = 512;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)]
+#[serde(transparent)]
+pub struct HostLogCursor(#[schemars(length(max = 512))] String);
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct CursorData {
+ root: String,
+ device: u64,
+ file: u64,
+ offset: u64,
+}
+
+impl HostLogCursor {
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ fn decode(&self) -> io::Result<CursorData> {
+ if self.0.len() > CURSOR_LIMIT {
+ return Err(invalid("log cursor is too long"));
+ }
+ let raw = URL_SAFE_NO_PAD
+ .decode(&self.0)
+ .map_err(|_| invalid("invalid log cursor"))?;
+ let data: CursorData =
+ serde_json::from_slice(&raw).map_err(|_| invalid("invalid log cursor"))?;
+ validate_root(&data.root)?;
+ Ok(data)
+ }
+
+ fn encode(data: CursorData) -> io::Result<Self> {
+ let bytes = serde_json::to_vec(&data).map_err(io::Error::other)?;
+ Ok(Self(URL_SAFE_NO_PAD.encode(bytes)))
+ }
+}
+
+impl fmt::Display for HostLogCursor {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+impl FromStr for HostLogCursor {
+ type Err = io::Error;
+ fn from_str(value: &str) -> io::Result<Self> {
+ Self::try_from(value.to_owned())
+ }
+}
+impl TryFrom<String> for HostLogCursor {
+ type Error = io::Error;
+ fn try_from(value: String) -> io::Result<Self> {
+ let cursor = Self(value);
+ cursor.decode()?;
+ Ok(cursor)
+ }
+}
+impl<'de> Deserialize<'de> for HostLogCursor {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ Self::try_from(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum HostLogPosition {
+ Tail,
+ After { cursor: HostLogCursor },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct HostLogRequest {
+ pub position: HostLogPosition,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum HostLogReset {
+ Replaced,
+ Truncated,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(
+ tag = "kind",
+ rename_all = "snake_case",
+ rename_all_fields = "camelCase",
+ deny_unknown_fields
+)]
+pub enum HostLogPage {
+ Missing,
+ Data {
+ cursor: HostLogCursor,
+ bytes: String,
+ reset: Option<HostLogReset>,
+ truncated: bool,
+ has_more: bool,
+ },
+}
+
+#[derive(Clone, Debug)]
+pub struct Source {
+ root_id: String,
+ path: PathBuf,
+}
+
+impl Source {
+ pub fn from_owner(owner: &RootOwner) -> io::Result<Self> {
+ owner.validate_current()?;
+ Ok(Self {
+ root_id: owner.root_id().to_owned(),
+ path: owner.control_directory().join("host.stderr.log"),
+ })
+ }
+
+ /// Derive a source without opening, creating, or changing any filesystem entry.
+ pub fn for_root(root_id: &str, namespaces: &RootNamespaces) -> io::Result<Self> {
+ validate_root(root_id)?;
+ Ok(Self {
+ root_id: root_id.to_owned(),
+ path: namespaces.control.join(root_id).join("host.stderr.log"),
+ })
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ /// Safely create/open the fixed private file for append-only process capture.
+ pub fn open_capture(&self) -> io::Result<File> {
+ filesystem::capture(&self.path)
+ }
+
+ pub async fn read(&self, request: HostLogRequest) -> io::Result<HostLogPage> {
+ let source = self.clone();
+ tokio::task::spawn_blocking(move || source.read_blocking(request))
+ .await
+ .map_err(io::Error::other)?
+ }
+
+ /// Serial delivery supplies backpressure without a queue or detached worker.
+ pub async fn watch<F, Fut>(
+ &self,
+ request: HostLogRequest,
+ cancel: CancellationToken,
+ mut emit: F,
+ ) -> io::Result<()>
+ where
+ F: FnMut(HostLogPage) -> Fut,
+ Fut: Future<Output = io::Result<()>>,
+ {
+ let mut position = request.position;
+ let mut previous = None;
+ loop {
+ if cancel.is_cancelled() {
+ return Ok(());
+ }
+ // Do not abandon spawn_blocking on cancellation. Await this bounded
+ // read before returning, then discard its result if cancelled.
+ let result = self
+ .read(HostLogRequest {
+ position: position.clone(),
+ })
+ .await;
+ if cancel.is_cancelled() {
+ return Ok(());
+ }
+ let page = result?;
+ let mut has_more = false;
+ if let HostLogPage::Data {
+ cursor,
+ has_more: more,
+ ..
+ } = &page
+ {
+ position = HostLogPosition::After {
+ cursor: cursor.clone(),
+ };
+ has_more = *more;
+ }
+ let changed = match (&page, &previous) {
+ (
+ HostLogPage::Data {
+ cursor,
+ bytes,
+ reset: None,
+ ..
+ },
+ Some(HostLogPage::Data { cursor: prior, .. }),
+ ) if bytes.is_empty() => cursor != prior,
+ _ => previous.as_ref() != Some(&page),
+ };
+ if changed {
+ tokio::select! {
+ biased;
+ _ = cancel.cancelled() => return Ok(()),
+ result = emit(page.clone()) => result?,
+ }
+ }
+ previous = Some(page);
+ if !has_more {
+ tokio::select! {
+ _ = cancel.cancelled() => return Ok(()),
+ _ = tokio::time::sleep(Duration::from_millis(250)) => {},
+ }
+ }
+ }
+ }
+
+ fn read_blocking(&self, request: HostLogRequest) -> io::Result<HostLogPage> {
+ use std::io::{Read, Seek, SeekFrom};
+ let tail = matches!(&request.position, HostLogPosition::Tail);
+ let after = match request.position {
+ HostLogPosition::Tail => None,
+ HostLogPosition::After { cursor } => {
+ let data = cursor.decode()?;
+ if data.root != self.root_id {
+ return Err(invalid("log cursor belongs to another Root"));
+ }
+ Some(data)
+ }
+ };
+ let Some(mut file) = filesystem::read(&self.path)? else {
+ return Ok(HostLogPage::Missing);
+ };
+ let (device, identity) = filesystem::identity(&file)?;
+ let end = file.metadata()?.len();
+ let (start, reset) = match after {
+ Some(cursor) if (cursor.device, cursor.file) != (device, identity) => (
+ end.saturating_sub(RAW_LIMIT as u64),
+ Some(HostLogReset::Replaced),
+ ),
+ Some(cursor) if cursor.offset > end => (
+ end.saturating_sub(RAW_LIMIT as u64),
+ Some(HostLogReset::Truncated),
+ ),
+ Some(cursor) => (cursor.offset, None),
+ None => (end.saturating_sub(RAW_LIMIT as u64), None),
+ };
+ file.seek(SeekFrom::Start(start))?;
+ let mut bytes = Vec::with_capacity(RAW_LIMIT);
+ file.take((end - start).min(RAW_LIMIT as u64))
+ .read_to_end(&mut bytes)?;
+ let offset = start + bytes.len() as u64;
+ let cursor = HostLogCursor::encode(CursorData {
+ root: self.root_id.clone(),
+ device,
+ file: identity,
+ offset,
+ })?;
+ Ok(HostLogPage::Data {
+ cursor,
+ bytes: STANDARD.encode(bytes),
+ reset,
+ truncated: start != 0 && (tail || reset.is_some()),
+ has_more: offset < end,
+ })
+ }
+}
+
+fn invalid(message: &str) -> io::Error {
+ io::Error::new(io::ErrorKind::InvalidInput, message)
+}
+fn validate_root(root: &str) -> io::Result<()> {
+ if root.len() == 64
+ && root
+ .bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+ {
+ Ok(())
+ } else {
+ Err(invalid(
+ "Root ID must contain 64 lowercase hexadecimal characters",
+ ))
+ }
+}
diff --git a/platform/host-runtime/src/logs/filesystem.rs b/platform/host-runtime/src/logs/filesystem.rs
new file mode 100644
index 0000000000..db9634bec3
--- /dev/null
+++ b/platform/host-runtime/src/logs/filesystem.rs
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::{fs::File, io, path::Path};
+
+fn denied() -> io::Error {
+ io::Error::new(
+ io::ErrorKind::PermissionDenied,
+ "Host log must be private, regular, singly linked, and stably named",
+ )
+}
+
+pub(super) fn capture(path: &Path) -> io::Result<File> {
+ let parent = path.parent().ok_or_else(denied)?;
+ maka_event_log::root::private_directory(parent)?;
+ open(path, true)?.ok_or_else(denied)
+}
+
+pub(super) fn read(path: &Path) -> io::Result<Option<File>> {
+ open(path, false)
+}
+
+#[cfg(unix)]
+pub(super) fn identity(file: &File) -> io::Result<(u64, u64)> {
+ use std::os::unix::fs::MetadataExt;
+ let metadata = file.metadata()?;
+ Ok((metadata.dev(), metadata.ino()))
+}
+
+#[cfg(windows)]
+pub(super) fn identity(file: &File) -> io::Result<(u64, u64)> {
+ let id = maka_event_log::root::windows::file_identity(file)?;
+ Ok((id.volume, id.index))
+}
+
+#[cfg(unix)]
+fn directory(path: &Path) -> io::Result<File> {
+ use std::{
+ fs::OpenOptions,
+ os::unix::fs::{MetadataExt, OpenOptionsExt},
+ };
+ let file = OpenOptions::new()
+ .read(true)
+ .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_NONBLOCK)
+ .open(path)?;
+ let metadata = file.metadata()?;
+ if !metadata.is_dir()
+ || metadata.uid() != unsafe { libc::geteuid() }
+ || metadata.mode() & 0o077 != 0
+ {
+ return Err(denied());
+ }
+ Ok(file)
+}
+
+#[cfg(unix)]
+fn open(path: &Path, create: bool) -> io::Result<Option<File>> {
+ use std::{
+ ffi::CString,
+ os::{
+ fd::{AsRawFd, FromRawFd},
+ unix::fs::MetadataExt,
+ },
+ };
+ let parent = path.parent().ok_or_else(denied)?;
+ let directory = match directory(parent) {
+ Ok(directory) => directory,
+ Err(error) if !create && error.kind() == io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => return Err(error),
+ };
+ // The basename is fixed by Source, never accepted from a wire request.
+ let name = CString::new("host.stderr.log").map_err(io::Error::other)?;
+ let flags = libc::O_NOFOLLOW
+ | libc::O_NONBLOCK
+ | libc::O_CLOEXEC
+ | if create {
+ libc::O_WRONLY | libc::O_APPEND | libc::O_CREAT
+ } else {
+ libc::O_RDONLY
+ };
+ // SAFETY: owned directory FD and NUL-terminated fixed basename; mode is only
+ // used for O_CREAT. Relative open pins the checked containing directory.
+ let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags, 0o600) };
+ if fd < 0 {
+ let error = io::Error::last_os_error();
+ if !create && error.kind() == io::ErrorKind::NotFound {
+ stable_directory(&directory, parent)?;
+ return Ok(None);
+ }
+ return Err(error);
+ }
+ // SAFETY: openat returned exactly one new owned FD.
+ let file = unsafe { File::from_raw_fd(fd) };
+ let metadata = file.metadata()?;
+ if !metadata.is_file()
+ || metadata.uid() != unsafe { libc::geteuid() }
+ || metadata.mode() & 0o077 != 0
+ || metadata.nlink() != 1
+ {
+ return Err(denied());
+ }
+ let mut named = std::mem::MaybeUninit::<libc::stat>::uninit();
+ // SAFETY: live directory/name and correctly sized output storage.
+ if unsafe {
+ libc::fstatat(
+ directory.as_raw_fd(),
+ name.as_ptr(),
+ named.as_mut_ptr(),
+ libc::AT_SYMLINK_NOFOLLOW,
+ )
+ } != 0
+ {
+ return Err(io::Error::last_os_error());
+ }
+ // SAFETY: successful fstatat initialized the entire record.
+ let named = unsafe { named.assume_init() };
+ // libc's device ID width differs between macOS and Linux.
+ #[allow(clippy::unnecessary_cast)]
+ let named_device = named.st_dev as u64;
+ if (metadata.dev(), metadata.ino()) != (named_device, named.st_ino) {
+ return Err(denied());
+ }
+ stable_directory(&directory, parent)?;
+ Ok(Some(file))
+}
+
+#[cfg(unix)]
+fn stable_directory(file: &File, path: &Path) -> io::Result<()> {
+ let named = directory(path)?;
+ if identity(file)? != identity(&named)? {
+ return Err(denied());
+ }
+ Ok(())
+}
+
+#[cfg(windows)]
+fn directory(path: &Path) -> io::Result<File> {
+ use maka_event_log::root::windows::{open_nofollow, validate_private};
+ let file = open_nofollow(path, false)?;
+ validate_private(&file)?;
+ if !file.metadata()?.is_dir() {
+ return Err(denied());
+ }
+ Ok(file)
+}
+
+#[cfg(windows)]
+fn open(path: &Path, create: bool) -> io::Result<Option<File>> {
+ use maka_event_log::root::windows::{file_identity, open_nofollow, validate_private};
+ let parent = path.parent().ok_or_else(denied)?;
+ let parent_handle = match directory(parent) {
+ Ok(file) => file,
+ Err(error) if !create && error.kind() == io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => return Err(error),
+ };
+ let opened = if create {
+ append(path)
+ } else {
+ open_nofollow(path, false)
+ };
+ let file = match opened {
+ Ok(file) => file,
+ Err(error) if !create && error.kind() == io::ErrorKind::NotFound => {
+ if identity(&parent_handle)? != identity(&directory(parent)?)? {
+ return Err(denied());
+ }
+ return Ok(None);
+ }
+ Err(error) => return Err(error),
+ };
+ validate_private(&file)?;
+ let id = file_identity(&file)?;
+ if !file.metadata()?.is_file() || id.links != 1 {
+ return Err(denied());
+ }
+ let named = open_nofollow(path, false)?;
+ if identity(&file)? != identity(&named)?
+ || identity(&parent_handle)? != identity(&directory(parent)?)?
+ {
+ return Err(denied());
+ }
+ Ok(Some(file))
+}
+
+#[cfg(windows)]
+fn append(path: &Path) -> io::Result<File> {
+ use maka_event_log::root::windows::PrivateSecurity;
+ use std::{
+ os::windows::{ffi::OsStrExt, fs::MetadataExt, io::FromRawHandle},
+ ptr,
+ };
+ use windows_sys::Win32::{
+ Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE},
+ Storage::FileSystem::{
+ CreateFileW, FILE_APPEND_DATA, FILE_ATTRIBUTE_REPARSE_POINT,
+ FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
+ OPEN_ALWAYS,
+ },
+ };
+ let name: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
+ let security = PrivateSecurity::current_account()?;
+ // SAFETY: owned pathname/private descriptor remain live for CreateFileW;
+ // append-only access and shared delete allow readers and replacement.
+ let handle = unsafe {
+ CreateFileW(
+ name.as_ptr(),
+ GENERIC_READ | FILE_APPEND_DATA,
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+ &security.attributes(),
+ OPEN_ALWAYS,
+ FILE_FLAG_OPEN_REPARSE_POINT,
+ ptr::null_mut(),
+ )
+ };
+ if handle == INVALID_HANDLE_VALUE {
+ return Err(io::Error::last_os_error());
+ }
+ // SAFETY: successful CreateFileW transfers one owned handle.
+ let file = unsafe { File::from_raw_handle(handle) };
+ if file.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
+ return Err(denied());
+ }
+ Ok(file)
+}
diff --git a/platform/host-runtime/src/logs/tests.rs b/platform/host-runtime/src/logs/tests.rs
new file mode 100644
index 0000000000..02b3b3315e
--- /dev/null
+++ b/platform/host-runtime/src/logs/tests.rs
@@ -0,0 +1,283 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use std::io::Write;
+use tokio::sync::mpsc;
+
+fn fixture_source(temp: &tempfile::TempDir, digit: char) -> Source {
+ Source::for_root(
+ &digit.to_string().repeat(64),
+ &RootNamespaces {
+ ownership: temp.path().join("owners"),
+ control: temp.path().join("control"),
+ },
+ )
+ .unwrap()
+}
+fn tail() -> HostLogRequest {
+ HostLogRequest {
+ position: HostLogPosition::Tail,
+ }
+}
+fn after(cursor: HostLogCursor) -> HostLogRequest {
+ HostLogRequest {
+ position: HostLogPosition::After { cursor },
+ }
+}
+fn data(page: HostLogPage) -> (HostLogCursor, Vec<u8>, Option<HostLogReset>, bool, bool) {
+ match page {
+ HostLogPage::Data {
+ cursor,
+ bytes,
+ reset,
+ truncated,
+ has_more,
+ } => (
+ cursor,
+ STANDARD.decode(bytes).unwrap(),
+ reset,
+ truncated,
+ has_more,
+ ),
+ HostLogPage::Missing => panic!("expected captured file"),
+ }
+}
+
+#[tokio::test]
+async fn bounded_binary_tail_and_forward_cursor_preserve_exact_bytes() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = fixture_source(&temp, 'a');
+ assert_eq!(source.read(tail()).await.unwrap(), HostLogPage::Missing);
+ assert!(
+ !source.path().parent().unwrap().exists(),
+ "read must not create a directory"
+ );
+ let mut writer = source.open_capture().unwrap();
+ let (_, empty, reset, truncated, more) = data(source.read(tail()).await.unwrap());
+ assert!(empty.is_empty());
+ assert_eq!((reset, truncated, more), (None, false, false));
+ let initial: Vec<_> = (0..RAW_LIMIT + 17).map(|i| i as u8).collect();
+ writer.write_all(&initial).unwrap();
+ let page = source.read(tail()).await.unwrap();
+ assert!(serde_json::to_vec(&page).unwrap().len() <= 48 * 1024);
+ let (cursor, bytes, reset, truncated, more) = data(page);
+ assert_eq!(bytes, initial[17..]);
+ assert_eq!((reset, truncated, more), (None, true, false));
+ let later: Vec<_> = (0..RAW_LIMIT + 9).map(|i| (i % 251) as u8).collect();
+ writer.write_all(&later).unwrap();
+ let serialized = serde_json::to_string(&after(cursor)).unwrap();
+ let request = serde_json::from_str(&serialized).unwrap();
+ let (cursor, bytes, reset, truncated, more) = data(source.read(request).await.unwrap());
+ assert_eq!(bytes, later[..RAW_LIMIT]);
+ assert_eq!((reset, truncated, more), (None, false, true));
+ let (cursor, bytes, reset, truncated, more) = data(source.read(after(cursor)).await.unwrap());
+ assert_eq!(bytes, later[RAW_LIMIT..]);
+ assert_eq!((reset, truncated, more), (None, false, false));
+ // Deliberately split a UTF-8 sequence and include invalid UTF-8. Cursors
+ // address raw bytes, not independently decoded text chunks.
+ writer.write_all(&[0xe4, 0xb8]).unwrap();
+ let (cursor, bytes, ..) = data(source.read(after(cursor)).await.unwrap());
+ assert_eq!(bytes, [0xe4, 0xb8]);
+ writer.write_all(&[0xad, 0xff, 0]).unwrap();
+ let (_, bytes, ..) = data(source.read(after(cursor)).await.unwrap());
+ assert_eq!(bytes, [0xad, 0xff, 0]);
+}
+
+#[tokio::test]
+async fn replacement_shrink_and_foreign_or_invalid_requests_are_explicit() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = fixture_source(&temp, 'b');
+ source
+ .open_capture()
+ .unwrap()
+ .write_all(b"original-long")
+ .unwrap();
+ let (cursor, ..) = data(source.read(tail()).await.unwrap());
+ let parked = source.path().with_extension("old");
+ std::fs::rename(source.path(), &parked).unwrap();
+ assert_eq!(
+ source.read(after(cursor.clone())).await.unwrap(),
+ HostLogPage::Missing
+ );
+ source
+ .open_capture()
+ .unwrap()
+ .write_all(b"replacement")
+ .unwrap();
+ let (cursor, bytes, reset, _, _) = data(source.read(after(cursor)).await.unwrap());
+ assert_eq!(bytes, b"replacement");
+ assert_eq!(reset, Some(HostLogReset::Replaced));
+ let mut writer = std::fs::OpenOptions::new()
+ .write(true)
+ .open(source.path())
+ .unwrap();
+ writer.set_len(0).unwrap();
+ writer.write_all(b"new").unwrap();
+ let (cursor, bytes, reset, _, _) = data(source.read(after(cursor)).await.unwrap());
+ assert_eq!(bytes, b"new");
+ assert_eq!(reset, Some(HostLogReset::Truncated));
+ let other = fixture_source(&temp, 'c');
+ assert_eq!(
+ other.read(after(cursor)).await.unwrap_err().kind(),
+ io::ErrorKind::InvalidInput
+ );
+ for input in ["bad".to_owned(), "a".repeat(513)] {
+ assert!(input.parse::<HostLogCursor>().is_err());
+ }
+ assert!(
+ serde_json::from_value::<HostLogRequest>(
+ serde_json::json!({"position":{"kind":"tail"},"path":"/tmp/secret"})
+ )
+ .is_err()
+ );
+ assert!(
+ Source::for_root(
+ "../other",
+ &RootNamespaces {
+ ownership: temp.path().into(),
+ control: temp.path().into()
+ }
+ )
+ .is_err()
+ );
+}
+
+#[tokio::test]
+async fn watch_retains_cursor_across_missing_and_settles_idle_cancellation() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = fixture_source(&temp, 'd');
+ source.open_capture().unwrap().write_all(b"before").unwrap();
+ let (cursor, ..) = data(source.read(tail()).await.unwrap());
+ let parked = source.path().with_extension("parked");
+ std::fs::rename(source.path(), &parked).unwrap();
+ let cancel = CancellationToken::new();
+ let watching = source.clone();
+ let cancellation = cancel.clone();
+ // A bounded delivery lane proves watch does not rely on unbounded buffering.
+ let (send, mut receive) = mpsc::channel(1);
+ let task = tokio::spawn(async move {
+ watching
+ .watch(after(cursor), cancellation, |page| {
+ let send = send.clone();
+ async move { send.send(page).await.map_err(io::Error::other) }
+ })
+ .await
+ });
+ let first = tokio::time::timeout(Duration::from_secs(3), receive.recv())
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(first, HostLogPage::Missing);
+ let mut writer = std::fs::OpenOptions::new()
+ .append(true)
+ .open(&parked)
+ .unwrap();
+ writer.write_all(b"after").unwrap();
+ std::fs::rename(&parked, source.path()).unwrap();
+ let next = tokio::time::timeout(Duration::from_secs(3), receive.recv())
+ .await
+ .unwrap()
+ .unwrap();
+ let (_, bytes, reset, _, _) = data(next);
+ assert_eq!(
+ bytes, b"after",
+ "missing must not discard the original byte cursor"
+ );
+ assert_eq!(reset, None);
+ cancel.cancel();
+ tokio::time::timeout(Duration::from_secs(3), task)
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+ assert!(receive.recv().await.is_none());
+}
+
+#[tokio::test]
+async fn source_does_not_keep_root_ownership_alive() {
+ let temp = tempfile::tempdir().unwrap();
+ let namespaces = RootNamespaces {
+ ownership: temp.path().join("owners"),
+ control: temp.path().join("control"),
+ };
+ let root_path = temp.path().join("root");
+ let owner = RootOwner::create(&root_path, &namespaces).unwrap();
+ let source = Source::from_owner(&owner).unwrap();
+ drop(owner);
+ let reopened = RootOwner::open(&root_path, &namespaces).unwrap();
+ source
+ .open_capture()
+ .unwrap()
+ .write_all(b"still readable")
+ .unwrap();
+ assert_eq!(
+ data(source.read(tail()).await.unwrap()).1,
+ b"still readable"
+ );
+ drop(reopened);
+}
+
+#[cfg(unix)]
+#[tokio::test]
+async fn reads_reject_nonprivate_files_links_and_symlink_directories() {
+ use std::os::unix::fs::{PermissionsExt, symlink};
+ let temp = tempfile::tempdir().unwrap();
+ let source = fixture_source(&temp, 'e');
+ source
+ .open_capture()
+ .unwrap()
+ .write_all(b"private")
+ .unwrap();
+ std::fs::set_permissions(source.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
+ assert!(source.read(tail()).await.is_err());
+ assert_eq!(
+ std::fs::metadata(source.path())
+ .unwrap()
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o644,
+ "reader must not chmod"
+ );
+ std::fs::set_permissions(source.path(), std::fs::Permissions::from_mode(0o600)).unwrap();
+ let link = source.path().with_extension("link");
+ std::fs::hard_link(source.path(), &link).unwrap();
+ assert!(source.read(tail()).await.is_err());
+ assert!(source.open_capture().is_err());
+ std::fs::remove_file(&link).unwrap();
+ std::fs::rename(source.path(), &link).unwrap();
+ symlink(&link, source.path()).unwrap();
+ assert!(source.read(tail()).await.is_err());
+ assert!(source.open_capture().is_err());
+ std::fs::remove_file(source.path()).unwrap();
+ std::fs::rename(&link, source.path()).unwrap();
+ let parent = source.path().parent().unwrap();
+ std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
+ assert!(source.read(tail()).await.is_err());
+ assert_eq!(
+ std::fs::metadata(parent).unwrap().permissions().mode() & 0o777,
+ 0o755
+ );
+ std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).unwrap();
+ let moved = parent.with_extension("old");
+ std::fs::rename(parent, &moved).unwrap();
+ symlink(&moved, parent).unwrap();
+ assert!(source.read(tail()).await.is_err());
+}
diff --git a/platform/js-runtime/src/plugin/engine.rs b/platform/js-runtime/src/plugin/engine.rs
index a928c9b69f..176a223f2f 100644
--- a/platform/js-runtime/src/plugin/engine.rs
+++ b/platform/js-runtime/src/plugin/engine.rs
@@ -61,8 +61,12 @@ fn runtime_base(health: &Arc<Health>, limits: &Limits, realm: super::Realm) -> R
runtime
.execute_script(
"maka-plugin-globals",
- r#"for (const [path, names] of [
+ r#"const { DOMException } = Deno.core.loadExtScript('ext:deno_web/01_dom_exception.js');
+ Deno.core.registerErrorBuilder('DOMExceptionInvalidCharacterError',
+ message => new DOMException(message, 'InvalidCharacterError'));
+ for (const [path, names] of [
['ext:deno_web/08_text_encoding.js', ['TextEncoder', 'TextDecoder']],
+ ['ext:deno_web/05_base64.js', ['atob']],
['ext:deno_web/00_url.js', ['URL', 'URLSearchParams']],
]) {
const exports = Deno.core.loadExtScript(path);
diff --git a/platform/js-runtime/tests/integration/plugin_vm/captured.rs b/platform/js-runtime/tests/integration/plugin_vm/captured.rs
index fb0bcd88da..f4d054fbf9 100644
--- a/platform/js-runtime/tests/integration/plugin_vm/captured.rs
+++ b/platform/js-runtime/tests/integration/plugin_vm/captured.rs
@@ -340,3 +340,26 @@ async fn pure_worker_has_no_ambient_host_ops() {
vm.shutdown().await;
}).await.unwrap();
}
+
+#[tokio::test]
+async fn pure_worker_decodes_binary_base64_without_ambient_host_capabilities() {
+ tokio::time::timeout(Duration::from_secs(10), async {
+ let vm = Vm::pure(Limits::default()).unwrap();
+ let module = vm.load("codec.mjs".into(), r#"
+ export function decode() {
+ const bytes = Uint8Array.from(atob('AP8b5Lit5paH8J+Zgg=='), c => c.charCodeAt(0));
+ let invalid;
+ try { atob('invalid!'); } catch (error) { invalid = error.name; }
+ return {bytes: Array.from(bytes), text: new TextDecoder().decode(bytes.subarray(3)),
+ invalid, deno: typeof Deno, buffer: typeof Buffer};
+ }
+ "#.into()).unwrap();
+ let result = module.call(vec!["decode".into()], vec![]).await;
+ module.close().await.unwrap();
+ vm.shutdown().await;
+ assert_eq!(result.unwrap(), json!({
+ "bytes": [0, 255, 27, 228, 184, 173, 230, 150, 135, 240, 159, 153, 130],
+ "text": "中文🙂", "invalid": "InvalidCharacterError", "deno": "undefined", "buffer": "undefined"
+ }));
+ }).await.unwrap();
+}
diff --git a/platform/process/src/detached.rs b/platform/process/src/detached.rs
index ae05724b13..a063b12a85 100644
--- a/platform/process/src/detached.rs
+++ b/platform/process/src/detached.rs
@@ -28,14 +28,18 @@ pub use tokio::process::Child;
pub use windows::{Child, spawn};
#[cfg(unix)]
-pub async fn spawn(executable: &std::path::Path, args: &[&str]) -> std::io::Result<Child> {
+pub async fn spawn(
+ executable: &std::path::Path,
+ args: &[&str],
+ stderr: Option<std::fs::File>,
+) -> std::io::Result<Child> {
use std::process::Stdio;
let mut command = tokio::process::Command::new(executable);
command
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
- .stderr(Stdio::null());
+ .stderr(stderr.map(Stdio::from).unwrap_or_else(Stdio::null));
// SAFETY: setsid is async-signal-safe and touches no Rust state.
unsafe {
command.pre_exec(|| {
diff --git a/platform/process/src/detached/windows.rs b/platform/process/src/detached/windows.rs
index ed797c2c2b..5128584e04 100644
--- a/platform/process/src/detached/windows.rs
+++ b/platform/process/src/detached/windows.rs
@@ -22,7 +22,7 @@ use crate::{
windows::{attributes::Attributes, checked, owned, pipe, try_wait_process, wait_process},
};
use std::{
- fs::OpenOptions,
+ fs::{File, OpenOptions},
io,
mem::size_of,
os::windows::io::{AsRawHandle, OwnedHandle},
@@ -32,7 +32,9 @@ use std::{
};
use tokio::net::windows::named_pipe::NamedPipeServer;
use windows_sys::Win32::{
- Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation},
+ Foundation::{
+ DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE_FLAG_INHERIT, SetHandleInformation,
+ },
System::Threading::*,
};
@@ -65,7 +67,7 @@ impl Child {
}
}
-pub async fn spawn(executable: &Path, args: &[&str]) -> io::Result<Child> {
+pub async fn spawn(executable: &Path, args: &[&str], stderr: Option<File>) -> io::Result<Child> {
if !executable.is_absolute() {
return Err(io::Error::other("detached executable must be absolute"));
}
@@ -81,7 +83,26 @@ pub async fn spawn(executable: &Path, args: &[&str]) -> io::Result<Child> {
let mut line = wide(Path::new(&arguments))?;
let (stdin, reader) = pipe::pty_input().await?;
let null = OpenOptions::new().read(true).write(true).open("NUL")?;
- let handles = [reader.as_raw_handle(), null.as_raw_handle()];
+ // Duplicate rather than change inheritance on the caller's capture handle.
+ let mut duplicate = ptr::null_mut();
+ // SAFETY: the selected owned file and current process remain live through duplication.
+ unsafe {
+ checked(DuplicateHandle(
+ GetCurrentProcess(),
+ stderr.as_ref().unwrap_or(&null).as_raw_handle(),
+ GetCurrentProcess(),
+ &mut duplicate,
+ 0,
+ 1,
+ DUPLICATE_SAME_ACCESS,
+ ))?;
+ }
+ let capture = owned(duplicate)?;
+ let handles = [
+ reader.as_raw_handle(),
+ null.as_raw_handle(),
+ capture.as_raw_handle(),
+ ];
// SAFETY: owned pipe/NUL handles, inherited only through the explicit list.
for handle in handles {
unsafe {
@@ -98,7 +119,7 @@ pub async fn spawn(executable: &Path, args: &[&str]) -> io::Result<Child> {
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = handles[0];
startup.StartupInfo.hStdOutput = handles[1];
- startup.StartupInfo.hStdError = handles[1];
+ startup.StartupInfo.hStdError = handles[2];
startup.lpAttributeList = attributes.as_ptr();
let mut process = PROCESS_INFORMATION::default();
// SAFETY: all buffers/handles outlive this call; environment and cwd inherit.
diff --git a/plugins/agent/agent-screen/actions.ts b/plugins/agent/agent-screen/actions.ts
index dc020e17b9..c3c91c4f68 100644
--- a/plugins/agent/agent-screen/actions.ts
+++ b/plugins/agent/agent-screen/actions.ts
@@ -52,6 +52,7 @@ import { toolCards } from '../../shared/tool-cards.js';
import { readingKey } from './state.js';
import { newGoalState, watchGoal } from './goal.js';
import { newPlanState, watchPlan } from './plan.js';
+import { readHostLogs } from './host-logs.js';
import { newDiagnostics, readDiagnostics } from './diagnostics.js';
import { newGraphState, watchGraph } from './graph.js';
import { messages } from './messages.js';
@@ -379,6 +380,7 @@ export function follow(state: State, actions: Action[]) {
}
export function select(state: State, actions: Action[], session: AgentSession | null) {
+ const hostOpen = state.diagnostics.source === 'host' && state.inspector === 'diagnostics';
const previous = state.visits.find((visit) => visit.id === state.selected?.id);
if (previous)
Object.assign(previous, {
@@ -392,7 +394,7 @@ export function select(state: State, actions: Action[], session: AgentSession |
landing: state.landing,
searchOpen: state.searchOpen,
browse: state.browse,
- inspector: state.inspector,
+ inspector: hostOpen ? null : state.inspector,
});
let visit = session
? state.visits.find(
@@ -426,7 +428,7 @@ export function select(state: State, actions: Action[], session: AgentSession |
].slice(-3);
state.searchOpen = visit?.searchOpen ?? false;
state.browse = visit?.browse ?? null;
- state.inspector = visit?.inspector ?? null;
+ state.inspector = hostOpen ? 'diagnostics' : (visit?.inspector ?? null);
state.recallAnchor = null;
state.selected = session;
state.backendReady = false;
@@ -435,7 +437,11 @@ export function select(state: State, actions: Action[], session: AgentSession |
state.executor = null;
state.modelRefreshing = false;
state.epoch++;
- state.diagnostics = newDiagnostics(session?.id, state.epoch);
+ state.diagnostics = {
+ ...newDiagnostics(session?.id, state.epoch),
+ source: state.diagnostics.source,
+ host: state.diagnostics.host,
+ };
state.graph = newGraphState(state.graph.enabled);
if (session) watchGraph(state);
state.plan = newPlanState();
@@ -730,7 +736,7 @@ export function rootCommands(
id: 'diagnostics-open',
label: text.diagnosticsTitleLabel,
hint: '',
- disabled: !route || !state.selected,
+ disabled: !route || (!state.selected && !state.hostLogsEnabled),
},
{
kind: 'compact-open',
@@ -1104,7 +1110,9 @@ export function rootCommand(
if (kind === 'diagnostics-open') {
if (target === 'tui') state.inspector = 'diagnostics';
else state.dialog = 'diagnostics';
- readDiagnostics(state, actions);
+ if (!state.selected && state.hostLogsEnabled) state.diagnostics.source = 'host';
+ if (state.diagnostics.source === 'host' && state.hostLogsEnabled) readHostLogs(state, actions);
+ else readDiagnostics(state, actions);
} else if (kind === 'compact-open') openCompact(state, actions);
else if (kind === 'outputs-open') {
state.inspector = 'outputs';
diff --git a/plugins/agent/agent-screen/desktop.ts b/plugins/agent/agent-screen/desktop.ts
index 92d60b7655..ee42c36c6c 100644
--- a/plugins/agent/agent-screen/desktop.ts
+++ b/plugins/agent/agent-screen/desktop.ts
@@ -30,6 +30,7 @@ export function activate(configuration: {
web?: boolean;
jev?: boolean;
graphs?: boolean;
+ hostLogs?: boolean;
}) {
component = agentScreen(
presentations.desktop,
@@ -41,6 +42,7 @@ export function activate(configuration: {
configuration.web === true,
configuration.jev === true,
configuration.graphs === true,
+ configuration.hostLogs === true,
);
return {
AgentScreen: { kind: 'component', contract: agentScreenContract, component: ['component'] },
diff --git a/plugins/agent/agent-screen/diagnostics.ts b/plugins/agent/agent-screen/diagnostics.ts
index f5076e906e..5f9bd7ed27 100644
--- a/plugins/agent/agent-screen/diagnostics.ts
+++ b/plugins/agent/agent-screen/diagnostics.ts
@@ -29,8 +29,18 @@ import { type Presentation, type Node, fill } from '../../shared/view.js';
import { copy, type Copy } from './locale.js';
import { intent } from './actions.js';
import type { State } from './state.js';
+import {
+ newHostLogs,
+ hostLogsView,
+ readHostLogs,
+ stopHostLogs,
+ hostLogEvent,
+ type HostLogsState,
+} from './host-logs.js';
export interface DiagnosticsState {
+ source: 'conversation' | 'host';
+ host: HostLogsState;
watch: { key: string; input: { sessionId: string } } | null;
revision: number;
through: number;
@@ -49,6 +59,8 @@ export type DiagnosticsRequest = {
};
export function newDiagnostics(sessionId?: string, epoch = 0): DiagnosticsState {
return {
+ source: 'conversation',
+ host: newHostLogs(),
watch: sessionId ? { key: 'diagnostics-watch-' + epoch, input: { sessionId } } : null,
revision: 0,
through: 0,
@@ -61,14 +73,17 @@ export function newDiagnostics(sessionId?: string, epoch = 0): DiagnosticsState
};
}
function open(state: State) {
- return state.inspector === 'diagnostics' || state.dialog === 'diagnostics';
+ return (
+ state.diagnostics.source === 'conversation' &&
+ (state.inspector === 'diagnostics' || state.dialog === 'diagnostics')
+ );
}
export function readDiagnostics(
state: State,
actions: Action[],
position: AgentDiagnostics['position'] = { kind: 'latest' },
) {
- if (!state.selected) return;
+ if (!state.selected || state.diagnostics.source !== 'conversation') return;
if (!state.diagnostics.watch)
state.diagnostics.watch = {
key: `diagnostics-watch-${state.epoch}-${++state.serial}`,
@@ -119,9 +134,18 @@ export function diagnosticResult(
diagnostics.failed = false;
}
export function diagnosticEvent(state: State, actions: Action[], key: string): boolean {
+ if (hostLogEvent(state, actions, key)) return true;
if (!key.startsWith('diagnostic')) return false;
const diagnostics = state.diagnostics;
- if (key === 'diagnostics-close') {
+ if (key === 'diagnostics-source-host' && state.hostLogsEnabled) {
+ diagnostics.source = 'host';
+ readHostLogs(state, actions);
+ } else if (key === 'diagnostics-source-conversation' && state.selected) {
+ stopHostLogs(state);
+ diagnostics.source = 'conversation';
+ readDiagnostics(state, actions);
+ } else if (key === 'diagnostics-close') {
+ stopHostLogs(state);
if (state.dialog === 'diagnostics') state.dialog = null;
if (state.inspector === 'diagnostics') state.inspector = null;
} else if (key === 'diagnostics-back') diagnostics.selected = null;
@@ -257,94 +281,117 @@ export function diagnosticsView<N extends Node>(
],
{ row: true, gap: 1 },
),
- ...(diagnostics.failed
- ? [ui.text('diagnostics-error', text.recoveryRefreshFailed, 13, theme.warning)]
- : []),
- ...(selected
+ ...(state.hostLogsEnabled
? [
- ui.button('diagnostics-back', text.back),
- ui.text('diagnostic-detail-source', source(selected, text), 15, theme.text, 550),
- ui.text(
- 'diagnostic-detail-cause',
- cause(selected, text),
- 14,
- failureColor(selected, theme),
- ),
- ui.text(
- 'diagnostic-detail-time',
- new Date(selected.recordedAt).toISOString(),
- 12,
- theme.muted,
- ),
- ui.text(
- 'diagnostic-detail-invocation',
- selected.detail.invocation.invocation_id,
- 12,
- theme.muted,
- ),
- ui.text('diagnostic-detail-event', selected.eventId, 12, theme.muted),
- ...(selected.detail.kind === 'model_step'
- ? [ui.text('diagnostic-detail-step', selected.detail.stepId, 12, theme.muted)]
- : []),
- ]
- : [
- presentation.reading(
- 'diagnostics-reading',
- {
- command: null,
- content: ui.box(
- 'diagnostics-records',
- diagnostics.page
- ? diagnostics.page.entries.length
- ? diagnostics.page.entries.map((record) => {
- const tone = failureColor(record, theme);
- return ui.box(
- 'diagnostic-' + record.sequence,
- [
- ui.button('diagnostic-open-' + record.sequence, source(record, text)),
- ui.text(
- 'diagnostic-cause-' + record.sequence,
- cause(record, text),
- 13,
- tone,
- ),
- ui.text(
- 'diagnostic-time-' + record.sequence,
- new Date(record.recordedAt).toISOString(),
- 12,
- theme.muted,
- ),
- ],
- { gap: 0 },
- );
- })
- : [ui.text('diagnostics-empty', text.diagnosticsEmpty, 13, theme.muted)]
- : [ui.text('diagnostics-loading', text.loading, 13, theme.muted)],
- {
- gap: 1,
- scroll: 'manual',
- terminal: { grow: 1 },
- style: { width: fill, height: fill },
- },
- ),
- },
- {},
- ),
ui.box(
- 'diagnostics-controls',
+ 'diagnostics-sources',
[
ui.button(
- 'diagnostics-refresh',
- diagnostics.position.kind === 'before' ? text.latest : text.check,
- ),
- ui.button(
- 'diagnostics-older',
- text.older,
- diagnostics.loading !== null || diagnostics.page?.nextBefore == null,
+ 'diagnostics-source-conversation',
+ text.diagnosticsConversation,
+ !state.selected,
),
+ ui.button('diagnostics-source-host', text.hostLogsLabel),
],
{ row: true, gap: 1 },
),
+ ]
+ : []),
+ ...(diagnostics.source === 'host' && state.hostLogsEnabled
+ ? [hostLogsView(presentation, state, theme, locale)]
+ : [
+ ...(diagnostics.failed
+ ? [ui.text('diagnostics-error', text.recoveryRefreshFailed, 13, theme.warning)]
+ : []),
+ ...(selected
+ ? [
+ ui.button('diagnostics-back', text.back),
+ ui.text('diagnostic-detail-source', source(selected, text), 15, theme.text, 550),
+ ui.text(
+ 'diagnostic-detail-cause',
+ cause(selected, text),
+ 14,
+ failureColor(selected, theme),
+ ),
+ ui.text(
+ 'diagnostic-detail-time',
+ new Date(selected.recordedAt).toISOString(),
+ 12,
+ theme.muted,
+ ),
+ ui.text(
+ 'diagnostic-detail-invocation',
+ selected.detail.invocation.invocation_id,
+ 12,
+ theme.muted,
+ ),
+ ui.text('diagnostic-detail-event', selected.eventId, 12, theme.muted),
+ ...(selected.detail.kind === 'model_step'
+ ? [ui.text('diagnostic-detail-step', selected.detail.stepId, 12, theme.muted)]
+ : []),
+ ]
+ : [
+ presentation.reading(
+ 'diagnostics-reading',
+ {
+ command: null,
+ content: ui.box(
+ 'diagnostics-records',
+ diagnostics.page
+ ? diagnostics.page.entries.length
+ ? diagnostics.page.entries.map((record) => {
+ const tone = failureColor(record, theme);
+ return ui.box(
+ 'diagnostic-' + record.sequence,
+ [
+ ui.button(
+ 'diagnostic-open-' + record.sequence,
+ source(record, text),
+ ),
+ ui.text(
+ 'diagnostic-cause-' + record.sequence,
+ cause(record, text),
+ 13,
+ tone,
+ ),
+ ui.text(
+ 'diagnostic-time-' + record.sequence,
+ new Date(record.recordedAt).toISOString(),
+ 12,
+ theme.muted,
+ ),
+ ],
+ { gap: 0 },
+ );
+ })
+ : [ui.text('diagnostics-empty', text.diagnosticsEmpty, 13, theme.muted)]
+ : [ui.text('diagnostics-loading', text.loading, 13, theme.muted)],
+ {
+ gap: 1,
+ scroll: 'manual',
+ terminal: { grow: 1 },
+ style: { width: fill, height: fill },
+ },
+ ),
+ },
+ {},
+ ),
+ ui.box(
+ 'diagnostics-controls',
+ [
+ ui.button(
+ 'diagnostics-refresh',
+ diagnostics.position.kind === 'before' ? text.latest : text.check,
+ ),
+ ui.button(
+ 'diagnostics-older',
+ text.older,
+ diagnostics.loading !== null || diagnostics.page?.nextBefore == null,
+ ),
+ ],
+ { row: true, gap: 1 },
+ ),
+ ]),
]),
],
{
diff --git a/plugins/agent/agent-screen/events.ts b/plugins/agent/agent-screen/events.ts
index ec21e80ec9..531906e56a 100644
--- a/plugins/agent/agent-screen/events.ts
+++ b/plugins/agent/agent-screen/events.ts
@@ -31,6 +31,7 @@ import type {
Emission,
} from '../../../packages/plugin-sdk/src/index.js';
import { appearanceEvent, effectiveLocale } from '../../shared/appearance-editor.js';
+import { hostLogUpdate } from './host-logs.js';
import { diagnosticChange } from './diagnostics.js';
import { observeBookmark, retryBookmark } from '../../shared/bookmark.js';
import type { AgentScreenProps, Theme } from '../../shared/contracts.js';
@@ -202,7 +203,15 @@ export function handleEvent<N extends Node>(
return frame(presentation, configuredLocale, state, values, actions);
if (input.kind === 'action') {
const outcome = input.event as ActionOutcome;
- if (state.diagnostics.watch?.key === input.key) {
+ if (state.diagnostics.host.watch?.key === input.key) {
+ if (outcome.status === 'updated')
+ hostLogUpdate(
+ state,
+ outcome.value as import('../../../packages/plugin-sdk/src/index.js').HostLogPage,
+ );
+ else if (outcome.status === 'failed' || outcome.status === 'not_accepted')
+ hostLogUpdate(state, null);
+ } else if (state.diagnostics.watch?.key === input.key) {
if (outcome.status === 'updated')
diagnosticChange(
state,
diff --git a/plugins/agent/agent-screen/frame.ts b/plugins/agent/agent-screen/frame.ts
index cb635348ed..e441a22f23 100644
--- a/plugins/agent/agent-screen/frame.ts
+++ b/plugins/agent/agent-screen/frame.ts
@@ -50,6 +50,7 @@ import {
type Presentation,
} from '../../shared/view.js';
+import { hostLogsOpen, stopHostLogs } from './host-logs.js';
import { diagnosticsView } from './diagnostics.js';
import { checklistView } from './checklist-view.js';
import { goalView } from './goal.js';
@@ -83,6 +84,11 @@ export function frame<N extends Node>(
actions: Action[] = [],
events: Emission[] = [],
) {
+ if (
+ !hostLogsOpen(state) &&
+ (state.diagnostics.host.watch || state.diagnostics.host.loading !== null)
+ )
+ stopHostLogs(state);
const target = presentation.target;
const currentPage = state.pages.at(-1);
if (currentPage && !state.openedPages.includes(currentPage)) state.openedPages.push(currentPage);
@@ -248,7 +254,9 @@ export function frame<N extends Node>(
]),
];
const inspectorOpen =
- conversationRoute(state) && !!state.selected && (state.inspector !== null || inlineInteraction);
+ conversationRoute(state) &&
+ ((!!state.selected && (state.inspector !== null || inlineInteraction)) ||
+ (hostLogsOpen(state) && state.inspector === 'diagnostics'));
const inspectorSlots = [
diagnosticsView(presentation, state, theme, locale),
graphView(presentation, state, theme, locale),
@@ -706,6 +714,18 @@ export function frame<N extends Node>(
out.actions = actions;
out.events = events;
out.subscriptions = [
+ ...(hostLogsOpen(state) && state.diagnostics.host.watch
+ ? [
+ {
+ key: state.diagnostics.host.watch.key,
+ input: state.diagnostics.host.watch.input,
+ binding: 'hostLogs',
+ member: 0,
+ method: 'watch',
+ resources: {},
+ },
+ ]
+ : []),
...(state.diagnostics.watch
? [
{
diff --git a/plugins/agent/agent-screen/header-view.ts b/plugins/agent/agent-screen/header-view.ts
index f9cd2a7018..76ded3de52 100644
--- a/plugins/agent/agent-screen/header-view.ts
+++ b/plugins/agent/agent-screen/header-view.ts
@@ -180,7 +180,9 @@ export function headerView<N extends Node>(
ui.button(
key!,
icon!,
- key !== 'palette-open' && !state.selected,
+ key !== 'palette-open' &&
+ !state.selected &&
+ !(key === 'diagnostics-open' && state.hostLogsEnabled),
false,
{ background: 0 },
undefined,
diff --git a/plugins/agent/agent-screen/host-logs.ts b/plugins/agent/agent-screen/host-logs.ts
new file mode 100644
index 0000000000..21049a3c25
--- /dev/null
+++ b/plugins/agent/agent-screen/host-logs.ts
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type {
+ Action,
+ HostLogRequest,
+ HostLogPage,
+} from '../../../packages/plugin-sdk/src/index.js';
+import type { Theme } from '../../shared/contracts.js';
+import { type Node, type Presentation, fill } from '../../shared/view.js';
+import { copy } from './locale.js';
+import { intent, conversationRoute } from './actions.js';
+import type { State } from './state.js';
+
+const WINDOW_BYTES = 64 * 1024;
+export interface HostLogsState {
+ generation: number;
+ cursor: string | null;
+ bytes: number[];
+ status: 'unread' | 'missing' | 'data';
+ omitted: boolean;
+ reset: Extract<HostLogPage, { kind: 'data' }>['reset'];
+ loading: number | null;
+ failed: boolean;
+ watch: { key: string; input: HostLogRequest; generation: number } | null;
+}
+export type HostLogRead = {
+ kind: 'hostLogRead';
+ generation: number;
+ attempt: number;
+ position: HostLogRequest['position'];
+};
+export function newHostLogs(): HostLogsState {
+ return {
+ generation: 0,
+ cursor: null,
+ bytes: [],
+ status: 'unread',
+ omitted: false,
+ reset: null,
+ loading: null,
+ failed: false,
+ watch: null,
+ };
+}
+export function hostLogsOpen(state: State): boolean {
+ return (
+ conversationRoute(state) &&
+ state.hostLogsEnabled &&
+ state.diagnostics.source === 'host' &&
+ (state.inspector === 'diagnostics' || state.dialog === 'diagnostics')
+ );
+}
+export function stopHostLogs(state: State) {
+ const host = state.diagnostics.host;
+ host.generation++;
+ host.watch = null;
+ host.loading = null;
+}
+export function readHostLogs(
+ state: State,
+ actions: Action[],
+ position: HostLogRequest['position'] = { kind: 'tail' },
+) {
+ if (!state.hostLogsEnabled) return;
+ if (position.kind === 'tail') stopHostLogs(state);
+ const host = state.diagnostics.host,
+ attempt = ++state.serial;
+ host.loading = attempt;
+ host.failed = false;
+ intent(
+ state,
+ actions,
+ { kind: 'hostLogRead', generation: host.generation, attempt, position },
+ 'hostLogs',
+ 'read',
+ { position } satisfies HostLogRequest,
+ );
+}
+function accept(host: HostLogsState, page: HostLogPage, tail: boolean): boolean {
+ if (page.kind === 'missing') {
+ if (tail) {
+ host.bytes = [];
+ host.cursor = null;
+ host.omitted = false;
+ host.reset = null;
+ }
+ host.status = 'missing';
+ return true;
+ }
+ let bytes: number[];
+ try {
+ bytes = Array.from(Uint8Array.from(atob(page.bytes), (character) => character.charCodeAt(0)));
+ } catch {
+ return false;
+ }
+ if (!tail && !page.reset && page.cursor === host.cursor) {
+ host.status = 'data';
+ return true;
+ }
+ if (tail || page.reset) {
+ host.bytes = [];
+ host.omitted = false;
+ host.reset = page.reset;
+ }
+ host.bytes.push(...bytes);
+ host.omitted ||= page.truncated;
+ if (host.bytes.length > WINDOW_BYTES) {
+ host.bytes = host.bytes.slice(-WINDOW_BYTES);
+ host.omitted = true;
+ }
+ // Invalid UTF-8 expands to three-byte replacement characters. Bound the
+ // rendered SourceText as well as its raw bytes, retaining a contiguous suffix.
+ const encoder = new TextEncoder();
+ let renderedBytes = encoder.encode(logText(host.bytes)).length;
+ while (renderedBytes > WINDOW_BYTES) {
+ host.bytes = host.bytes.slice(Math.ceil((renderedBytes - WINDOW_BYTES) / 3));
+ host.omitted = true;
+ renderedBytes = encoder.encode(logText(host.bytes)).length;
+ }
+ if (host.omitted || page.reset) {
+ // A bounded tail can begin inside one UTF-8 character. Keep byte cursors opaque.
+ let drop = 0;
+ while (drop < 3 && drop < host.bytes.length && (host.bytes[drop]! & 0xc0) === 0x80) drop++;
+ if (drop) host.bytes = host.bytes.slice(drop);
+ }
+ host.cursor = page.cursor;
+ host.status = 'data';
+ return true;
+}
+export function hostLogResult(
+ state: State,
+ actions: Action[],
+ request: HostLogRead,
+ page: HostLogPage | null,
+) {
+ const host = state.diagnostics.host;
+ if (request.generation !== host.generation || request.attempt !== host.loading) return;
+ host.loading = null;
+ host.failed = !page || !accept(host, page, request.position.kind === 'tail');
+ if (page?.kind === 'data' && page.hasMore && !host.failed && hostLogsOpen(state))
+ readHostLogs(state, actions, { kind: 'after', cursor: page.cursor });
+}
+export function hostLogUpdate(state: State, page: HostLogPage | null) {
+ const host = state.diagnostics.host;
+ if (!hostLogsOpen(state) || !host.watch || host.watch.generation !== host.generation) return;
+ if (!page || !accept(host, page, false)) {
+ stopHostLogs(state);
+ host.failed = true;
+ } else host.failed = false;
+}
+export function hostLogEvent(state: State, actions: Action[], key: string): boolean {
+ if (!key.startsWith('host-log-')) return false;
+ if (!hostLogsOpen(state)) return true;
+ const host = state.diagnostics.host;
+ if (key === 'host-log-refresh') readHostLogs(state, actions);
+ else if (key === 'host-log-stop') stopHostLogs(state);
+ else if (key === 'host-log-follow' && host.loading === null && !host.watch) {
+ host.failed = false;
+ host.watch = {
+ key: 'host-log-watch-' + ++state.serial,
+ input: { position: host.cursor ? { kind: 'after', cursor: host.cursor } : { kind: 'tail' } },
+ generation: host.generation,
+ };
+ }
+ return true;
+}
+function logText(bytes: number[]): string {
+ return new TextDecoder().decode(new Uint8Array(bytes), { stream: true });
+}
+export function hostLogsView<N extends Node>(
+ presentation: Presentation<N>,
+ state: State,
+ theme: Theme,
+ locale: string,
+): N {
+ const ui = presentation.paint(theme),
+ text = copy(locale),
+ host = state.diagnostics.host;
+ const contents = logText(host.bytes);
+ return ui.box(
+ 'host-logs',
+ [
+ ui.text('host-log-scope', text.hostLogsLabel, 15, theme.text, 550),
+ ...(host.failed
+ ? [ui.text('host-log-error', text.recoveryRefreshFailed, 13, theme.warning)]
+ : []),
+ ...(host.omitted ? [ui.text('host-log-omitted', text.hostLogsOmitted, 12, theme.muted)] : []),
+ ...(host.reset
+ ? [
+ ui.text(
+ 'host-log-reset',
+ host.reset === 'replaced' ? text.hostLogsReplaced : text.hostLogsTruncated,
+ 12,
+ theme.warning,
+ ),
+ ]
+ : []),
+ ...(host.status === 'missing'
+ ? [ui.text('host-log-missing', text.hostLogsMissing, 13, theme.muted)]
+ : []),
+ ...(host.status === 'unread' && host.loading !== null
+ ? [ui.text('host-log-loading', text.loading, 13, theme.muted)]
+ : []),
+ presentation.reading(
+ 'host-log-reading',
+ {
+ command: null,
+ content: ui.box(
+ 'host-log-content',
+ [
+ presentation.slot(
+ 'host-log-text',
+ 'source',
+ {
+ source: contents,
+ appearance: theme.markdown.code,
+ selection: theme.markdown.selection,
+ terminal: theme.terminal,
+ disabled: false,
+ focusOnPointer: true,
+ },
+ {},
+ ),
+ ],
+ {
+ scroll: host.watch ? 'follow_end' : 'manual',
+ terminal: { grow: 1 },
+ style: { width: fill, height: fill },
+ },
+ ),
+ },
+ {},
+ ),
+ ui.box(
+ 'host-log-controls',
+ [
+ ui.button('host-log-refresh', text.check, host.loading !== null),
+ host.watch
+ ? ui.button('host-log-stop', text.stop)
+ : ui.button('host-log-follow', text.hostLogsFollow, host.loading !== null),
+ ],
+ { row: true, gap: 1 },
+ ),
+ ],
+ { gap: 1, terminal: { grow: 1 }, style: { width: fill, height: fill } },
+ );
+}
diff --git a/plugins/agent/agent-screen/index.ts b/plugins/agent/agent-screen/index.ts
index fe9e2ba158..4b54528f00 100644
--- a/plugins/agent/agent-screen/index.ts
+++ b/plugins/agent/agent-screen/index.ts
@@ -42,6 +42,7 @@ export function agentScreen<N extends Node>(
web = false,
jev = false,
graphs = false,
+ hostLogs = false,
): Component<State, AgentScreenProps, N> {
const target = presentation.target;
const configuredLocale = locale;
@@ -106,6 +107,7 @@ export function agentScreen<N extends Node>(
dialog: null,
sessionChange: null,
diagnostics: newDiagnostics(),
+ hostLogsEnabled: hostLogs,
inspector: null,
image: null,
window: null,
diff --git a/plugins/agent/agent-screen/locale.ts b/plugins/agent/agent-screen/locale.ts
index fb6c4c0c57..412f6e4320 100644
--- a/plugins/agent/agent-screen/locale.ts
+++ b/plugins/agent/agent-screen/locale.ts
@@ -19,6 +19,13 @@
import { createCopy } from '../../shared/locale.js';
import {
+ msg_diagnosticsConversation,
+ msg_hostLogsLabel,
+ msg_hostLogsMissing,
+ msg_hostLogsOmitted,
+ msg_hostLogsReplaced,
+ msg_hostLogsTruncated,
+ msg_hostLogsFollow,
msg_recoveryRefreshFailed,
msg_diagnosticsSessionBusy,
msg_diagnosticsContinuationChanged,
@@ -375,6 +382,13 @@ import type {
// This feature owns this explicit message catalog; shared messages remain tree-shakeable.
const messages = {
+ diagnosticsConversation: msg_diagnosticsConversation,
+ hostLogsLabel: msg_hostLogsLabel,
+ hostLogsMissing: msg_hostLogsMissing,
+ hostLogsOmitted: msg_hostLogsOmitted,
+ hostLogsReplaced: msg_hostLogsReplaced,
+ hostLogsTruncated: msg_hostLogsTruncated,
+ hostLogsFollow: msg_hostLogsFollow,
recoveryRefreshFailed: msg_recoveryRefreshFailed,
diagnosticsSessionBusy: msg_diagnosticsSessionBusy,
diagnosticsContinuationChanged: msg_diagnosticsContinuationChanged,
diff --git a/plugins/agent/agent-screen/package.ts b/plugins/agent/agent-screen/package.ts
index b3db205c39..547d7157af 100644
--- a/plugins/agent/agent-screen/package.ts
+++ b/plugins/agent/agent-screen/package.ts
@@ -26,6 +26,7 @@ const entries = uiEntries({
export: 'AgentScreen',
imports: {
agent: one(sdk.contracts.agent),
+ hostLogs: { contract: sdk.contracts.hostLogs, cardinality: { kind: 'optional' } },
workhub: { contract: sdk.contracts.workhubCoordinator, cardinality: { kind: 'optional' } },
reading: one(sdk.contracts.readingArea),
file: one(sdk.contracts.filePicker),
@@ -84,6 +85,7 @@ const entries = uiEntries({
web: { type: 'boolean' },
jev: { type: 'boolean' },
graphs: { type: 'boolean' },
+ hostLogs: { type: 'boolean' },
place: { type: 'string', maxLength: 4096 },
},
['locale'],
diff --git a/plugins/agent/agent-screen/results.ts b/plugins/agent/agent-screen/results.ts
index 556591890d..5f23ee0a91 100644
--- a/plugins/agent/agent-screen/results.ts
+++ b/plugins/agent/agent-screen/results.ts
@@ -18,6 +18,7 @@
*/
import { compactQuery, compactSettled } from './compact.js';
+import { hostLogResult } from './host-logs.js';
import { diagnosticResult } from './diagnostics.js';
import type {
Action,
@@ -306,6 +307,10 @@ export function receipt(state: State, actions: Action[], request: Mutation, repl
}
export function queryFailed(state: State, request: Request) {
+ if (request.kind === 'hostLogRead') {
+ hostLogResult(state, [], request, null);
+ return;
+ }
if (request.kind === 'diagnostics') {
diagnosticResult(state, request, null);
return;
@@ -441,6 +446,14 @@ export function queryFailed(state: State, request: Request) {
export function querySucceeded(state: State, actions: Action[], request: Request, value: unknown) {
switch (request.kind) {
+ case 'hostLogRead':
+ hostLogResult(
+ state,
+ actions,
+ request,
+ value as import('../../../packages/plugin-sdk/src/index.js').HostLogPage,
+ );
+ break;
case 'diagnostics':
diagnosticResult(
state,
diff --git a/plugins/agent/agent-screen/state.ts b/plugins/agent/agent-screen/state.ts
index ea62a31339..d5349f523c 100644
--- a/plugins/agent/agent-screen/state.ts
+++ b/plugins/agent/agent-screen/state.ts
@@ -65,6 +65,7 @@ import { type AttachmentDrafts } from '../../shared/attachments.js';
import { type ComposerReadiness } from '../../shared/drafts.js';
+import type { HostLogRead } from './host-logs.js';
import type { DiagnosticsState, DiagnosticsRequest } from './diagnostics.js';
import type { CompactState, CompactRequest } from './compact.js';
@@ -78,6 +79,7 @@ type BackendPreference = import('../../../packages/plugin-sdk/src/index.js').Age
type Notice = keyof Copy | null;
export type Request =
+ | HostLogRead
| DiagnosticsRequest
| CompactRequest
| { kind: 'sandboxChange'; epoch: number; sessionId: string }
@@ -303,6 +305,7 @@ export interface State {
insightsScope: InsightsProps['scope'];
sidebar: { open: boolean } | { command: TerminalPaneCommand | null };
diagnostics: DiagnosticsState;
+ hostLogsEnabled: boolean;
inspector:
| 'diagnostics'
| 'details'
diff --git a/plugins/agent/agent-screen/tui.ts b/plugins/agent/agent-screen/tui.ts
index 87add24830..f631858ab3 100644
--- a/plugins/agent/agent-screen/tui.ts
+++ b/plugins/agent/agent-screen/tui.ts
@@ -30,6 +30,7 @@ export function activate(configuration: {
web?: boolean;
jev?: boolean;
graphs?: boolean;
+ hostLogs?: boolean;
}) {
component = agentScreen(
presentations.tui,
@@ -41,6 +42,7 @@ export function activate(configuration: {
configuration.web === true,
configuration.jev === true,
configuration.graphs === true,
+ configuration.hostLogs === true,
);
return {
AgentScreen: { kind: 'component', contract: agentScreenContract, component: ['component'] },
diff --git a/plugins/application/gateway/package.ts b/plugins/application/gateway/package.ts
index 57f5049326..a57d4fc63e 100644
--- a/plugins/application/gateway/package.ts
+++ b/plugins/application/gateway/package.ts
@@ -33,6 +33,7 @@ export default {
...native(sdk.nativeImplementations.gateway),
imports: {
agent: one(sdk.contracts.agent),
+ hostLogs: { contract: sdk.contracts.hostLogs, cardinality: { kind: 'optional' } },
coordinator: { contract: sdk.contracts.agent, cardinality: { kind: 'optional' } },
workhub: {
contract: sdk.contracts.workhubCoordinator,
diff --git a/platform/host-runtime/src/lib.rs b/plugins/application/host-logs/package.ts
similarity index 56%
copy from platform/host-runtime/src/lib.rs
copy to plugins/application/host-logs/package.ts
index 147389f96c..e119d919f3 100644
--- a/platform/host-runtime/src/lib.rs
+++ b/plugins/application/host-logs/package.ts
@@ -17,16 +17,16 @@
* under the License.
*/
-pub mod application;
-mod directories;
-pub mod http;
-pub mod ipc;
-pub mod recovery;
-pub mod sandbox;
-pub mod shell;
-
-pub use directories::DirectoryRootSpec;
-pub type HostError = Box<dyn std::error::Error + Send + Sync>;
-
-pub mod credentials;
-pub mod storage;
+import * as sdk from '../../../packages/plugin-sdk/src/index.js';
+import { native } from '../../shared/package.js';
+export default {
+ schemaVersion: 1,
+ id: 'maka.application.host-logs',
+ displayName: 'Host logs',
+ description: 'Read and follow captured logs from the connected Host.',
+ localizations: {
+ 'zh-CN': { displayName: 'Host 日志', description: '查看和跟随所连接 Host 的已捕获日志。' },
+ 'zh-TW': { displayName: 'Host 日誌', description: '查看和跟隨所連線 Host 的已擷取日誌。' },
+ },
+ application: { entries: { host: native(sdk.nativeImplementations.hostLogs) } },
+};
diff --git a/plugins/shared/locale-messages.ts b/plugins/shared/locale-messages.ts
index 9d9d76ec64..0335786129 100644
--- a/plugins/shared/locale-messages.ts
+++ b/plugins/shared/locale-messages.ts
@@ -3089,3 +3089,27 @@ export const msg_toggleSidebar: Message = ['Toggle sidebar', '切换侧栏', '
export const msg_showSidebar: Message = ['Show sidebar', '显示侧栏', '顯示側欄'];
export const msg_hideSidebar: Message = ['Hide sidebar', '收起侧栏', '收起側欄'];
export const msg_connecting: Message = ['Connecting…', '正在连接…', '正在連線…'];
+
+export const msg_diagnosticsConversation: Message = ['Conversation', '对话', '對話'];
+export const msg_hostLogsLabel: Message = ['Host logs', 'Host 日志', 'Host 日誌'];
+export const msg_hostLogsMissing: Message = [
+ 'Host log capture file is currently missing.',
+ '当前没有 Host 日志捕获文件。',
+ '目前沒有 Host 日誌擷取檔案。',
+];
+export const msg_hostLogsOmitted: Message = [
+ 'Earlier log bytes omitted.',
+ '已省略较早的日志。',
+ '已省略較早的日誌。',
+];
+export const msg_hostLogsReplaced: Message = [
+ 'Log file replaced; showing the new file.',
+ '日志文件已替换,正在显示新文件。',
+ '日誌檔案已替換,正在顯示新檔案。',
+];
+export const msg_hostLogsTruncated: Message = [
+ 'Log file truncated; showing the current tail.',
+ '日志文件已截断,正在显示当前末尾。',
+ '日誌檔案已截斷,正在顯示目前末尾。',
+];
+export const msg_hostLogsFollow: Message = ['Follow', '跟随', '跟隨'];
diff --git a/products/maka/bundle/package-inputs.json b/products/maka/bundle/package-inputs.json
index 8bed215fab..dccaa3f8cb 100644
--- a/products/maka/bundle/package-inputs.json
+++ b/products/maka/bundle/package-inputs.json
@@ -4,6 +4,7 @@
"deployment-settings": "plugins/application/deployment-settings",
"deployment": "plugins/application/deployment",
"workhub-root": "plugins/workhub/workhub-root",
+ "host-logs": "plugins/application/host-logs",
"agent-screen": "plugins/agent/agent-screen",
"apps": "plugins/application/apps",
"skill-library": "plugins/skills/skill-library",
diff --git a/products/maka/bundle/src/composition.ts b/products/maka/bundle/src/composition.ts
index 0e0b943610..ed5a3e56ff 100644
--- a/products/maka/bundle/src/composition.ts
+++ b/products/maka/bundle/src/composition.ts
@@ -79,6 +79,7 @@ export const packageNames = [
'deployment',
'deployment-settings',
'host-deployment-manager',
+ 'host-logs',
'deployment-manager',
'deployment-gateway',
'image-viewer',
@@ -210,6 +211,7 @@ function settingsComposition(
| 'workspace-jev'
| 'skills'
| 'skill-outcomes'
+ | 'host-logs'
| 'host-deployment-manager',
exportName: string,
): BindingTarget => ({
@@ -234,6 +236,7 @@ function settingsComposition(
environment: { location: 'host' },
instances: {
'host-deployment-manager': node('host-deployment-manager'),
+ ...(gateway === 'gateway' ? { 'host-logs': node('host-logs') } : {}),
'host-deployment-gateway': node(
'deployment-gateway',
{},
@@ -260,6 +263,7 @@ function settingsComposition(
gateway,
{},
{
+ ...(gateway === 'gateway' ? { hostLogs: [local('host-logs', 'HostLogs')] } : {}),
settings: [local('settings', 'Settings')],
authentication: [local('settings-authentication', 'Authentication')],
setup: [local('settings-setup', 'Setup')],
@@ -930,6 +934,7 @@ function agentComposition(configuration: CompositionConfiguration): ApplicationS
{
locale: configuration.locale,
terminals: !!configuration.processes,
+ hostLogs: true,
skills: !!configuration.skills,
scheduledTasks: !!configuration.agent.scheduledTasks,
web: !!configuration.web,
@@ -938,6 +943,7 @@ function agentComposition(configuration: CompositionConfiguration): ApplicationS
},
{
agent: [remote('agent', 'agent')],
+ hostLogs: [remote('host-logs', 'HostLogs')],
reading: [local('reading-area', 'ReadingArea')],
file: [local('file', 'FilePicker')],
receipts: [remote('receipts', 'Receipts')],
@@ -1236,12 +1242,17 @@ function agentComposition(configuration: CompositionConfiguration): ApplicationS
...(configuration.presentation === 'tui' ? { diff: node('diff') } : {}),
});
ui.launch = local('agent-screen', 'AgentScreen');
+ remoteExports.push({
+ capability: { deployment: 'local', instance: [keys['host-logs']], export: 'HostLogs' },
+ declaration: { contract: contracts.hostLogs, availability: 'required' },
+ });
remoteExports.unshift({
capability: { deployment: 'local', instance: [keys.agent], export: 'agent' },
declaration: { contract: contracts.agent, availability: 'required' },
});
Object.assign(connections.hosts.find((host) => host.name === 'local')!.endpoints, {
agent: expose('gateway', 'agent', Object.keys(contracts.agent.interface.methods), true),
+ hostLogs: expose('gateway', 'hostLogs', ['read', 'watch'], false),
});
if (configuration.processes) {
remoteExports.push({
diff --git a/products/maka/bundle/tests/task-workbench.test.mjs b/products/maka/bundle/tests/task-workbench.test.mjs
index 08c4cd5e47..7e574a6ce6 100644
--- a/products/maka/bundle/tests/task-workbench.test.mjs
+++ b/products/maka/bundle/tests/task-workbench.test.mjs
@@ -23,6 +23,7 @@ import Ajv from 'ajv/dist/2020.js';
const appearance = await import('../dist/packages/appearance/tui.js');
const theme = appearance.activate({ scheme: 'dark' }).Appearance.value;
const module = await import('../dist/packages/agent-screen/tui.js');
+const desktopScreen = await import('../dist/packages/agent-screen/desktop.js');
module.activate({ locale: 'en' });
const values = { appearance: [theme] };
const dataSource = {
@@ -73,9 +74,10 @@ function nodes(node) {
]
: [];
}
-function client(locale = 'en') {
- module.activate({ locale });
- let out = module.component.mount({}, values);
+function client(locale = 'en', options = {}) {
+ const screen = options.target === 'desktop' ? desktopScreen : module;
+ screen.activate({ locale, ...options.configuration });
+ let out = screen.component.mount({}, values);
const c = {
get out() {
return out;
@@ -84,7 +86,7 @@ function client(locale = 'en') {
return out.frame.state;
},
event(input) {
- out = module.component.event(c.state, input, {}, values);
+ out = screen.component.event(c.state, input, {}, values);
return c;
},
raw(key) {
@@ -116,10 +118,11 @@ function client(locale = 'en') {
return c.state.visits.find((visit) => visit.id === c.state.selected?.id).key;
},
};
- c.child('recovery-checkpoint', 'changed', {
- sequence: 0,
- state: { kind: 'ready', revision: null, value: null },
- });
+ if (options.target !== 'desktop')
+ c.child('recovery-checkpoint', 'changed', {
+ sequence: 0,
+ state: { kind: 'ready', revision: null, value: null },
+ });
c.reply(c.action('page'), []);
c.event({
kind: 'action',
@@ -127,7 +130,12 @@ function client(locale = 'en') {
event: {
status: 'updated',
value: {
- sessions: [session('Task A'), session('Task B'), session('Task C'), session('Task D')],
+ sessions: options.sessions ?? [
+ session('Task A'),
+ session('Task B'),
+ session('Task C'),
+ session('Task D'),
+ ],
before: null,
after: null,
},
@@ -1788,3 +1796,234 @@ test('Diagnostics reads common failures and refreshes late background results wi
['Another conversation'],
);
});
+
+test('Host logs open Diagnostics without a selected conversation through their own capability', () => {
+ const c = client('en', { configuration: { hostLogs: true }, sessions: [] });
+ assert.equal(c.state.selected, null);
+ c.raw('palette-open');
+ const command = c
+ .find('command-palette')
+ .props.commands.find((entry) => entry.id === 'diagnostics-open');
+ assert.ok(
+ command && !command.disabled,
+ 'Diagnostics must remain available for Host logs without a selected conversation',
+ );
+ c.child('command-palette', 'invoked', { id: 'diagnostics-open' });
+ const read = c.action('read');
+ assert.ok(read, 'Opening Host diagnostics must query the fixed Host log source');
+ assert.equal(read.binding, 'hostLogs');
+ assert.deepEqual(read.input, { position: { kind: 'tail' } });
+ assert.equal(c.action('diagnostics'), undefined);
+ assert.equal(c.action('observe'), undefined);
+ assert.equal(c.action('history'), undefined);
+ c.reply(read, {
+ kind: 'data',
+ cursor: 'opaque-host-cursor:+/==',
+ bytes: 'SG9zdCBzdGFydGVkCg==',
+ reset: null,
+ truncated: false,
+ hasMore: false,
+ });
+ assert.equal(c.find('host-log-text').props.source, 'Host started\n');
+});
+
+function openDiagnostics(c) {
+ c.raw('palette-open');
+ c.child('command-palette', 'invoked', { id: 'diagnostics-open' });
+ return c;
+}
+function hostPage(cursor, bytes, extra = {}) {
+ return { kind: 'data', cursor, bytes, reset: null, truncated: false, hasMore: false, ...extra };
+}
+function hostUpdate(c, watch, value) {
+ c.event({ kind: 'action', key: watch.key, event: { status: 'updated', value } });
+}
+test('Host logs render literal split UTF-8 in both presentations and stop their independent follow', () => {
+ for (const target of ['tui', 'desktop']) {
+ const c = openDiagnostics(
+ client('en', {
+ target,
+ configuration: { hostLogs: true },
+ sessions: [],
+ }),
+ );
+ // The first byte of 中 is deliberately separated from the remaining bytes.
+ c.reply(c.action('read'), hostPage('opaque:+/=first', '5A=='));
+ assert.equal(c.find('host-log-text').props.source, '');
+ c.raw('host-log-follow');
+ const watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+ assert.ok(watch);
+ assert.equal(watch.method, 'watch');
+ assert.deepEqual(watch.input, { position: { kind: 'after', cursor: 'opaque:+/=first' } });
+ hostUpdate(c, watch, hostPage('opaque:+/=second', 'uK0='));
+ assert.equal(c.find('host-log-text').props.source, '中');
+ const raw = '\x1b[31m<command>🙂\n';
+ hostUpdate(c, watch, hostPage('opaque:+/=third', Buffer.from(raw).toString('base64')));
+ assert.equal(c.find('host-log-text').props.source, '中' + raw);
+ assert.deepEqual(
+ c.out.subscriptions.find((entry) => entry.binding === 'hostLogs'),
+ watch,
+ );
+ assert.equal(
+ c.out.actions.some((entry) => entry.binding === 'terminals'),
+ false,
+ );
+ c.raw('host-log-stop');
+ assert.equal(
+ c.out.subscriptions.some((entry) => entry.binding === 'hostLogs'),
+ false,
+ );
+ hostUpdate(c, watch, hostPage('late', 'bGF0ZQ=='));
+ assert.equal(c.find('host-log-text').props.source, '中' + raw);
+ c.raw('host-log-follow');
+ assert.deepEqual(c.out.subscriptions.find((entry) => entry.binding === 'hostLogs').input, {
+ position: { kind: 'after', cursor: 'opaque:+/=third' },
+ });
+ c.raw('diagnostics-close');
+ assert.equal(
+ c.out.subscriptions.some((entry) => entry.binding === 'hostLogs'),
+ false,
+ );
+ }
+});
+test('Host logs retain a bounded tail, clear replaced bytes, and distinguish missing from empty capture', () => {
+ const c = openDiagnostics(client('zh-CN', { configuration: { hostLogs: true }, sessions: [] }));
+ c.reply(c.action('read'), { kind: 'missing' });
+ assert.equal(c.find('host-log-missing').text, '当前没有 Host 日志捕获文件。');
+ c.raw('host-log-follow');
+ const watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+ hostUpdate(c, watch, hostPage('empty', ''));
+ assert.equal(c.find('host-log-missing'), undefined);
+ const block = 'a'.repeat(32 * 1024);
+ for (const cursor of ['one', 'two', 'three'])
+ hostUpdate(c, watch, hostPage(cursor, Buffer.from(block).toString('base64')));
+ assert.equal(c.find('host-log-text').props.source, block + block);
+ assert.equal(c.find('host-log-omitted').text, '已省略较早的日志。');
+ hostUpdate(c, watch, hostPage('new-file', 'bmV3Cg==', { reset: 'replaced' }));
+ assert.equal(c.find('host-log-text').props.source, 'new\n');
+ assert.equal(c.find('host-log-omitted'), undefined);
+ assert.equal(c.find('host-log-reset').text, '日志文件已替换,正在显示新文件。');
+ hostUpdate(c, watch, hostPage('shrunk', 'dGFpbA==', { reset: 'truncated', truncated: true }));
+ assert.equal(c.find('host-log-text').props.source, 'tail');
+ assert.equal(c.find('host-log-omitted').text, '已省略较早的日志。');
+ assert.equal(c.find('host-log-reset').text, '日志文件已截断,正在显示当前末尾。');
+});
+test('Host log source survives session changes and stale closed reads cannot replace its new page', () => {
+ const c = openDiagnostics(
+ client('en', { configuration: { hostLogs: true } })
+ .observe(10)
+ .history(page(10, [])),
+ );
+ c.raw('diagnostics-source-host');
+ c.reply(c.action('read'), hostPage('kept-cursor', 'a2VwdA=='));
+ c.raw('host-log-follow');
+ const watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+ const transcript = c.out.subscriptions.find(
+ (entry) => entry.binding === 'agent' && entry.method === 'watch',
+ );
+ c.select('Task B');
+ assert.equal(c.find('host-log-text').props.source, 'kept');
+ assert.deepEqual(
+ c.out.subscriptions.find((entry) => entry.binding === 'hostLogs'),
+ watch,
+ );
+ c.raw('diagnostics-source-conversation');
+ assert.equal(
+ c.out.subscriptions.some((entry) => entry.binding === 'hostLogs'),
+ false,
+ );
+ assert.deepEqual(c.action('diagnostics').input, {
+ sessionId: 'Task B',
+ position: { kind: 'latest' },
+ });
+ assert.ok(transcript, 'Host follow coexists with the transcript subscription');
+ c.raw('diagnostics-source-host');
+ const oldRead = c.action('read');
+ c.raw('diagnostics-close');
+ openDiagnostics(c);
+ const newRead = c.action('read');
+ assert.notEqual(oldRead.key, newRead.key);
+ c.reply(newRead, hostPage('fresh', 'ZnJlc2g='));
+ c.reply(oldRead, hostPage('stale', 'c3RhbGU='));
+ assert.equal(c.find('host-log-text').props.source, 'fresh');
+});
+test('Omitting the Host logs feature creates no optional capability demands', () => {
+ const c = client('en', { sessions: [] });
+ c.raw('palette-open');
+ assert.equal(
+ c.find('command-palette').props.commands.find((entry) => entry.id === 'diagnostics-open')
+ .disabled,
+ true,
+ );
+ assert.equal(
+ c.out.actions.some((entry) => entry.binding === 'hostLogs'),
+ false,
+ );
+ assert.equal(
+ c.out.subscriptions.some((entry) => entry.binding === 'hostLogs'),
+ false,
+ );
+ assert.equal(c.find('host-log-text'), undefined);
+ const selected = openDiagnostics(client().observe(10).history(page(10, [])));
+ assert.ok(selected.action('diagnostics'));
+ assert.equal(selected.find('diagnostics-source-host'), undefined);
+ assert.equal(selected.find('host-log-text'), undefined);
+});
+
+test('Host log follow preserves its visible suffix through missing and same-file return while tail refresh starts over', () => {
+ const c = openDiagnostics(client('en', { configuration: { hostLogs: true }, sessions: [] }));
+ c.reply(
+ c.action('read'),
+ hostPage('same-file-cursor', 'a2VwdA==', { truncated: true, reset: 'replaced' }),
+ );
+ c.raw('host-log-follow');
+ let watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+ hostUpdate(c, watch, { kind: 'missing' });
+ assert.equal(c.find('host-log-text').props.source, 'kept');
+ assert.equal(c.find('host-log-missing').text, 'Host log capture file is currently missing.');
+ assert.equal(c.find('host-log-omitted').text, 'Earlier log bytes omitted.');
+ assert.equal(c.find('host-log-reset').text, 'Log file replaced; showing the new file.');
+ c.raw('host-log-stop');
+ c.raw('host-log-follow');
+ watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+ assert.deepEqual(watch.input, { position: { kind: 'after', cursor: 'same-file-cursor' } });
+ hostUpdate(c, watch, hostPage('same-file-cursor', ''));
+ assert.equal(c.find('host-log-missing'), undefined);
+ assert.equal(c.find('host-log-text').props.source, 'kept');
+ hostUpdate(c, watch, { kind: 'missing' });
+ hostUpdate(c, watch, hostPage('same-file-next-cursor', 'LXN1ZmZpeA=='));
+ assert.equal(c.find('host-log-text').props.source, 'kept-suffix');
+ assert.equal(c.find('host-log-missing'), undefined);
+ assert.equal(c.find('host-log-omitted').text, 'Earlier log bytes omitted.');
+ c.raw('host-log-refresh');
+ c.reply(c.action('read'), { kind: 'missing' });
+ assert.equal(c.find('host-log-text').props.source, '');
+ assert.equal(c.find('host-log-omitted'), undefined);
+ assert.equal(c.find('host-log-reset'), undefined);
+ c.raw('host-log-follow');
+ assert.deepEqual(c.out.subscriptions.find((entry) => entry.binding === 'hostLogs').input, {
+ position: { kind: 'tail' },
+ });
+});
+
+test('Host log invalid UTF-8 replacements stay within the rendered text budget and retain the newest suffix', () => {
+ const c = openDiagnostics(client('en', { configuration: { hostLogs: true }, sessions: [] }));
+ c.reply(
+ c.action('read'),
+ hostPage('binary-tail', Buffer.alloc(32 * 1024, 0xff).toString('base64')),
+ );
+ let displayed = c.find('host-log-text').props.source;
+ assert.ok(
+ Buffer.byteLength(displayed) <= 65536,
+ 'Decoded log text must fit the SourceText UTF-8 budget',
+ );
+ assert.ok(displayed.includes('\ufffd'));
+ assert.equal(c.find('host-log-omitted').text, 'Earlier log bytes omitted.');
+ c.raw('host-log-follow');
+ const watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+ hostUpdate(c, watch, hostPage('text-suffix', 'dGFpbAo='));
+ displayed = c.find('host-log-text').props.source;
+ assert.ok(Buffer.byteLength(displayed) <= 65536);
+ assert.ok(displayed.endsWith('tail\n'));
+ assert.ok(displayed.includes('\ufffd'));
+});
diff --git a/products/maka/cli/README.md b/products/maka/cli/README.md
index e00d53436d..65cc71a3a5 100644
--- a/products/maka/cli/README.md
+++ b/products/maka/cli/README.md
@@ -33,6 +33,8 @@ Startup discovers the local Host and connects to it, or starts one automatically
Headless commands (`host`, `application serve`, `code` and `acp`) install process logging before runtime initialization. Warnings and errors, including SQLx slow-query warnings, go to stderr without ANSI styling; stdout remains available for command results and protocol frames. Ordinary DEBUG query logs are disabled. Interactive products keep control of their terminal output.
+Dedicated Host processes capture stderr in their Root's private control-cache file `host.stderr.log`: on-demand capture starts before spawning, while foreground serving starts after Root selection. Read it with `maka host logs --root-id ID`, or add `--follow` for live NDJSON and `--after CURSOR` to resume. Pages preserve raw bytes as Base64 and report missing capture, replacement or truncation; clearing the control cache removes these logs. This works without an installed service. The de [...]
+
## Product updates, recovery and URL installation
To update a standalone Host's selected bundled plugins, stop that Host and use its State Root:
diff --git a/products/maka/cli/README.zh-CN.md b/products/maka/cli/README.zh-CN.md
index 831a879ed7..d78c3555d4 100644
--- a/products/maka/cli/README.zh-CN.md
+++ b/products/maka/cli/README.zh-CN.md
@@ -33,6 +33,8 @@
无界面命令(`host`、`application serve`、`code` 和 `acp`)在运行时初始化前安装进程日志订阅器。警告和错误(包括 SQLx 慢查询警告)写入 stderr,不带 ANSI 样式;stdout 保留给命令结果和协议帧。默认不输出普通 DEBUG 查询日志。交互式产品仍自行管理终端输出。
+独立 Host 将 stderr 捕获到所属 Root 的私有控制缓存文件 `host.stderr.log`:按需启动在创建进程前接好捕获文件,前台服务在选定 Root 后开始捕获。使用 `maka host logs --root-id ID` 读取,添加 `--follow` 持续接收 NDJSON,添加 `--after CURSOR` 从游标续读。每页以 Base64 保留原始字节,并报告尚无捕获文件、文件替换或截断;清理控制缓存也会删除这些日志。读取不要求安装系统服务。默认产品的“诊断 → Host 日志”无需会话即可读取和跟随日志。
+
## 产品更新、恢复与 URL 安装
更新独立 Host 已选的内置插件时,先停止该 Host,再指定它的 State Root:
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index de925091c6..2fb7e184c0 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -5,10 +5,10 @@
"package-lock.json": "a5af15aca21fabe0e1bdd48784167df4dc81cee17777d54861dcbd641168a4eb",
"package.json": "38baf3beeb5876849ca16e31868d59bfaee769f260c7555b4a8a1847b92e28a1",
"packages/plugin-sdk/src/configurations.ts": "20e587b47e84a31fc794176f85e7ac71c1b8b7f88a46947f9403011c307fff1e",
- "packages/plugin-sdk/src/contracts.ts": "955e194f5156a87bb56459325067ea0246b76d29096e80a5b6793cc823c2f699",
- "packages/plugin-sdk/src/generated/runtime.ts": "ff0e1c027c17ed026457993a95c168d2d9b0d334b609f97475ab7a2d91facafc",
+ "packages/plugin-sdk/src/contracts.ts": "d1207817a5c183cc99defa614ef73b5534e4eb8a79152e8c1c9abfd406211579",
+ "packages/plugin-sdk/src/generated/runtime.ts": "66e0d36196e303b32d8464cdecec86c419da124d9f29ce473e0070855a1b8fe2",
"packages/plugin-sdk/src/index.ts": "27b68be5a29d1857f7fe361c8c678e77ba21413c79ec3870c1ce30d0faf9323f",
- "packages/plugin-sdk/src/native.ts": "2cf374759664132f1eba27b3c49c375174ad7e748e6d28a68ed07dde7ab05bf7",
+ "packages/plugin-sdk/src/native.ts": "d29328923ca55874fe1a0fc37985097196d805a09b56043c1a3379fd46bd5a20",
"packages/plugin-sdk/src/ui.ts": "d6c64e9406c41ad6bbda73435b559aad24c31adb3c5bbb4ab6f49fff8a8330ca",
"patches/zod+4.6.5.patch": "5a6cbff1b31ca2a5d2b7c006abb7b1002241dbf4ae42c315c27940201f52b081",
"platform/background-health/Cargo.toml": "41e89dc7e43adf066306b03b3083cbd4163a5550f39d5be6669463d3c16c53a5",
@@ -43,33 +43,34 @@
"plugins/agent/acp-executor/engine/tests/support/tui-auth.mjs": "b66436ace65b5874b6cd3ab66ece94984552c4a0772a0ae2d9b05db6b9f47d0f",
"plugins/agent/acp-executor/native/Cargo.toml": "069e5db4798166d921dc48424101f9460516c8683f0356272e2398b847b460b4",
"plugins/agent/acp-executor/package.ts": "d85747d6f02da7a316859d04d87706bf425699b90bf4053198a64b50eabd391f",
- "plugins/agent/agent-screen/actions.ts": "b39d33100e4159484d206aa7f1f1f29dd1519bf3bc069f37b49d8977ea905034",
+ "plugins/agent/agent-screen/actions.ts": "1c885c291301131d8d39808327324eaa0d8c08c06146b2e15c9b37993fd3da00",
"plugins/agent/agent-screen/checklist-view.ts": "e2976d6408df0407af4c26c9221a143761197f800f7a4f620639dad80091192f",
"plugins/agent/agent-screen/compact.ts": "358817341afba4c6ca8cace67e1e547994888b2a91793b3620af020e5a255436",
"plugins/agent/agent-screen/component-events.ts": "bd1a04c613532bf2fc7c16997fd38565323fac77b2b38d710e6de815d34d0166",
"plugins/agent/agent-screen/composer-status.ts": "ff8380142e1b247d508b5be10d2089b353019de8a705169703fd53f3008b2db9",
"plugins/agent/agent-screen/conversation-view.ts": "aeac5aba3c9fa6687d3c683b89af31884d9bab1e0ada1750b91812f57e3a750e",
- "plugins/agent/agent-screen/desktop.ts": "9cd44cb6a5225feead1aa87fc4e2d7935b994022fa38175342330119e72c4660",
- "plugins/agent/agent-screen/diagnostics.ts": "282a5700b2f9845c49046d51dbcdc7cf65449fcfc728e482442105cb6a9de768",
- "plugins/agent/agent-screen/events.ts": "68f83b17ea8f772453e264731497a4fd1ed0e0fde35c520ce6523ffe2afa973a",
- "plugins/agent/agent-screen/frame.ts": "594e156e06b07387690b6671c5431c5c4609cf13e7690e3dc5851a65b22da9ee",
+ "plugins/agent/agent-screen/desktop.ts": "b856ee9f0c43f99df47c68070ba9845164e07b1282a30107a87e4fa6ef9da260",
+ "plugins/agent/agent-screen/diagnostics.ts": "49af51a1ba6791cb0d8cb81cf5a2d26c870e443f1435fc9344960a4421a029ae",
+ "plugins/agent/agent-screen/events.ts": "826dc21ee00ce952ce8910a530b8ab097140d72f9b4cede5272214d6c8e892bb",
+ "plugins/agent/agent-screen/frame.ts": "659fb6233b5edbc0be05edd94d9e32b04c4fd2668adbdbe2924f7ba25f1c2b97",
"plugins/agent/agent-screen/goal.ts": "b1ce1874dd617e7ace1fbea2f0dd2069277dbd0b12173f538d585db1b3bdc9f7",
"plugins/agent/agent-screen/graph.ts": "a60a38979fba5a0dd1dfa944f28ce8d1768c7cd84b3a95bbaddc6731a39922c1",
- "plugins/agent/agent-screen/header-view.ts": "ddb4e64e5c1cb10fa0a9b0d9f64b3223c74e2a4b3344193aafc8924088941c61",
- "plugins/agent/agent-screen/index.ts": "593fc28c0494ff2c624548c7fc2f2074da718c9b994a129d73438b68ef50cc1f",
- "plugins/agent/agent-screen/locale.ts": "bcd8dcf72200903cef623271f0ff6d928c3fdb97efccecf03fc518dcdefa92f2",
+ "plugins/agent/agent-screen/header-view.ts": "ba3c727a586e45561f59b756480fa2babaa80a9be718d1b9661353d6fb80c8a2",
+ "plugins/agent/agent-screen/host-logs.ts": "9cb76e7659a5134359420d24479fd2ce9c055a4260e718ff55972982cd67dce8",
+ "plugins/agent/agent-screen/index.ts": "4d3162d71038a22e58646d36669873bdf1159491174f65317f1ef55f37c313be",
+ "plugins/agent/agent-screen/locale.ts": "068a5c685a84f27adcfa8b22f8e85ceb94cd39b32775547af3f184532e513b0d",
"plugins/agent/agent-screen/messages.ts": "98b41c7631e043d73bb389a38da6aeb39b80f9f14fa0009abd07d950adc072d2",
"plugins/agent/agent-screen/navigation-view.ts": "4f4df48a7714eabe7159db161a03abddcc742cd5d71fe4cd488a7a301a3979a5",
"plugins/agent/agent-screen/outputs-view.ts": "c77e4b71ccd122bac25c0b2108f3261b3e3b84f3f024487cc389d7042f9932aa",
- "plugins/agent/agent-screen/package.ts": "2a9d1635153ff46fe52313bdb70934c0818ac99350665c509e4d47ba2401bd45",
+ "plugins/agent/agent-screen/package.ts": "43a1adec950132590ec3da890e3ce0ebf24c2f82cbf3f9f8ceb12f1c9415ee47",
"plugins/agent/agent-screen/pages-view.ts": "661e061479a340a98a53dd20ced7e814930f61d0433a133b05bce2a00181010c",
"plugins/agent/agent-screen/plan.ts": "88f600ede57041a4f83acd485c532324b8ab52842471985c5e8e2b8a76d50a00",
"plugins/agent/agent-screen/raw-events.ts": "b7bb99f638f7655168cdff083681b22862884c4e1f865f758e36959969a4c328",
"plugins/agent/agent-screen/recap-view.ts": "eba743e827b0fe0832430955f8d3d1f5a43dd37526a94ecf1d9daab666e58185",
- "plugins/agent/agent-screen/results.ts": "b605d48135986f93549753dcd042de3c27004872ca4476de3d7325bde621b921",
+ "plugins/agent/agent-screen/results.ts": "d8606a2cfe24b742db28a46f65ac93b50aa5f315226348c3ee61a685e539d4fd",
"plugins/agent/agent-screen/sandbox.ts": "344159383d98bd542f9614f2466f78ceee4b892adce3c9c13c355dd4783c17df",
- "plugins/agent/agent-screen/state.ts": "7c98a87c60dd820f09b4895f15e0a3b3f6ba7718ec0912d45c37f4a51e0a4797",
- "plugins/agent/agent-screen/tui.ts": "3b1de4b1aa847692e94b66262a1a6e8c21d1111d61f5d86ad28d13adb712d856",
+ "plugins/agent/agent-screen/state.ts": "318aab5b7bb3519eab43b92438d25eb73bca7e2aafec5f221177e8c6eb0ef188",
+ "plugins/agent/agent-screen/tui.ts": "9ae58c3f40030c6f5358f71d5bd8521741e0e98a3b39893a3242e7f7d386fcb7",
"plugins/agent/agent/package.ts": "d4929e8b6e276705dbb3049b840ea64f53dd7d52176e2036d2f769df5e7bc82f",
"plugins/agent/api/Cargo.toml": "7f33b8ad23980598820684a18fbde28a078a8624e951147564da3d121736fecc",
"plugins/agent/assistant-preferences/desktop.ts": "7f5cc8f2087bf85f6b32b94b9110f87cf2acb6d46da9d581efc19c886c2ecd80",
@@ -240,9 +241,10 @@
"plugins/application/deployment/state.ts": "85d3e24816f7260ea0ba576f6f0bbf342dcdf37b6ce8ea74264bde3d24689223",
"plugins/application/deployment/tui.ts": "c0b4613094b455d609f1136c29d997720fb9910a0e65c6cada72769faebd984b",
"plugins/application/deployment/view.ts": "9174e7942ec465b108e2c4c6006117ce0e54c023174adde04b0785764f0e7be6",
- "plugins/application/gateway/package.ts": "44a7c14c19f2d98f664a05b18c3dbc9f6616a19899efccea55930419860f79e1",
+ "plugins/application/gateway/package.ts": "ccbf5f3222a3703832288270046b063a03bac43f81f27917312f3a34303f9785",
"plugins/application/history/package.ts": "4174cd62ed483789ad47d6757b6ceeba95ab7c9fbba234264eda7f48b1cef477",
"plugins/application/host-deployment-manager/package.ts": "c5c390273e8adc6b1192b047d9ac7e5776b8a4cd422a99ead5765e8eed35cfdb",
+ "plugins/application/host-logs/package.ts": "a5e7360a36ab3f36daf9e1b57161bab81c3cf2a6254340c52e17fbd59109a615",
"plugins/application/receipts/package.ts": "cf15f59e2f8e924178d1ac11b22ff2c774c499aa8ca950154e51a98e7800cf95",
"plugins/application/recovery/desktop.ts": "c354b12b43ad745e4e0ab0cbefd5936814ac05acc57b7d22648045113bb0b76e",
"plugins/application/recovery/index.ts": "1da180c02283a986cbe9cea8ce671be9d7a7b0ee33d635527df6b1d1558454e3",
@@ -337,7 +339,7 @@
"plugins/shared/locale-catalogs/page-shell.ts": "9344fe797d2dfe699ba5b817e64cdc731877b67ed544f2595b28ae952672d4b5",
"plugins/shared/locale-catalogs/settings.ts": "94d9bfaf031b6d9887cda7c0eb09fc359da9e6ee3f652844a6d0a61bbed77868",
"plugins/shared/locale-catalogs/skill-import.ts": "80f9181724196d882a428dee893c04fe33fedefaaba5ebbfa4f7372148982294",
- "plugins/shared/locale-messages.ts": "dc239da1636b091e57d4f4ae299474047605a358b4d9f1aa3d36c64ea0341ec7",
+ "plugins/shared/locale-messages.ts": "54ae3d8a23ba41876143ad0dc1ceafee8c2f58d8529177d7248c09c31c1481fe",
"plugins/shared/locale.ts": "d3d772870643169345756a37af4718e52cdbeb59f5a2bbb9418023607b7ead1f",
"plugins/shared/message-copy.ts": "2f7bfab4b186e1ff5784d4b6e75218c02ac92e3b7f301d0c3047b6ada6fb5ad7",
"plugins/shared/message-view.ts": "a571ca1202cdc7e9cbb7365aa41b49f015bb3777d2405fd54bf671287cbfa9f4",
@@ -428,10 +430,10 @@
"plugins/workspace/workspace-web/native/Cargo.toml": "e40d175d4ed5300b1c43ecc3af89e9b90c806560b3bda65898a742b39559ca74",
"plugins/workspace/workspace-web/package.ts": "1708c7cef2dc40f54c732ed01988af9f3b5b435e3f4909c63a4b992ed82ec436",
"products/maka/acp/Cargo.toml": "7ca393447aafad9880cd40741c8abe80e903ad14d52cf20706ebfe5bbc88aa85",
- "products/maka/bundle/package-inputs.json": "b84a2d9fea512fc7ade745a04ec545b360dd752064aa3ea819832d915b8cf77d",
+ "products/maka/bundle/package-inputs.json": "38dc88014c68956c94be539febcb9547b6962b11851c1f184bf2e14d6fe0a92c",
"products/maka/bundle/package.json": "dd17e0a82ab68c03cdfd8adb3e243d4c5e881530db66ff311cb642b3bdc1f7c0",
"products/maka/bundle/scripts/build.mjs": "db11d8cd6f29ef7ead087261848568da1ad3889a870a89f23fa969449d769d47",
- "products/maka/bundle/src/composition.ts": "e6f1ef1d6f2a02616ba3416420704119e13310ec2585785cdc5dac4fbb657b18",
+ "products/maka/bundle/src/composition.ts": "c3ef908bb77fd6a65aeb38635709dec650c6b0a84840910df0b90433d342cfeb",
"products/maka/bundle/src/installation.ts": "614e9ed4ee41c26f9a398da21b1c5a4b41d91a22080a472d64207c5826e491fc",
"products/maka/cli/Cargo.toml": "2a41d9dd434d08bb62cfe784e32b9e9facd41c58bbe9eb1b809b3fde3a2ed446",
"products/maka/cli/build.rs": "98452274bb0f20fa55fda5a20313f81740e759652aa4227fba0b0de5e3f4cc29",
diff --git a/products/maka/cli/src/application/serve.rs b/products/maka/cli/src/application/serve.rs
index c80175e4fb..5e31046710 100644
--- a/products/maka/cli/src/application/serve.rs
+++ b/products/maka/cli/src/application/serve.rs
@@ -51,6 +51,16 @@ pub(super) fn native() -> Result<NativeRegistry, HostError> {
Ok(native)
}
pub(super) async fn run(args: Serve) -> Result<(), HostError> {
+ let owner = tokio::task::spawn_blocking(move || {
+ maka_event_log::root::RootOwner::open(
+ &args.root,
+ &maka_event_log::root::RootNamespaces::for_current_account()?,
+ )
+ })
+ .await??;
+ let logs = maka_host_runtime::logs::Source::from_owner(&owner)?;
+ eprintln!("Host diagnostics: {}", logs.path().display());
+ crate::logging::capture(logs.open_capture()?)?;
let endpoints: BTreeMap<String, HostEndpoint> = super::read_json(&args.endpoints)?;
let grants: Vec<NativeGrant> = args
.native
@@ -58,7 +68,7 @@ pub(super) async fn run(args: Serve) -> Result<(), HostError> {
.map(super::read_json)
.transpose()?
.unwrap_or_default();
- let store = Arc::new(super::open_store(args.root).await?);
+ let store = Arc::new(maka_transport::open_store(Arc::new(owner)).await?);
let host = Host::open_with_options(
store,
args.deployment,
diff --git a/products/maka/cli/src/args.rs b/products/maka/cli/src/args.rs
index 5ed7a95440..03ccdb2e0f 100644
--- a/products/maka/cli/src/args.rs
+++ b/products/maka/cli/src/args.rs
@@ -95,7 +95,7 @@ enum HostCommand {
Uninstall(crate::deployment::Control),
/// Observe a Host or managed deployment without starting or changing it.
Status(crate::deployment::Status),
- /// Read a bounded tail of supervised Host diagnostics without starting it.
+ /// Read or follow captured Host diagnostics without starting it.
Logs(crate::deployment::Logs),
/// Retire the exact current Host when idle, or explicitly interrupt its work.
Retire {
@@ -165,7 +165,8 @@ impl Cli {
}
let finite = matches!(&self.command, Some(Command::Host(command)) if !matches!(command,
HostCommand::Candidate(_) | HostCommand::Serve { .. } | HostCommand::ServiceRun(_)
- | HostCommand::Connect(_) | HostCommand::Ensure(_)));
+ | HostCommand::Connect(_) | HostCommand::Ensure(_))
+ && !matches!(command, HostCommand::Logs(logs) if logs.follow));
if finite {
let default = if matches!(
&self.command,
diff --git a/products/maka/cli/src/deployment/activation.rs b/products/maka/cli/src/deployment/activation.rs
index 4c9ea90dfc..0df99ea276 100644
--- a/products/maka/cli/src/deployment/activation.rs
+++ b/products/maka/cli/src/deployment/activation.rs
@@ -226,6 +226,8 @@ pub(super) async fn connect_or_launch(
super::service::start(deployment.clone(), lease.clone(), owner).await?;
service_started = true;
} else {
+ let capture = maka_host_runtime::logs::Source::from_owner(&owner)?
+ .open_capture()?;
drop(owner);
child = Some(
detached::spawn(
@@ -246,6 +248,7 @@ pub(super) async fn connect_or_launch(
"--initial-connection-timeout-ms",
"30000",
],
+ Some(capture),
)
.await?,
);
@@ -327,9 +330,10 @@ mod tests {
async fn failed_candidate_drains_or_reports_unconfirmed_without_killing_its_authority() {
// No descendants: each child is either an EOF-driven reader or exec'd sleep.
for (command, graceful) in [("read line; exit 0", true), ("exec sleep 60", false)] {
- let mut child = detached::spawn(std::path::Path::new("/bin/sh"), &["-c", command])
- .await
- .unwrap();
+ let mut child =
+ detached::spawn(std::path::Path::new("/bin/sh"), &["-c", command], None)
+ .await
+ .unwrap();
let result = tokio::time::timeout(
Duration::from_secs(3),
stop_failed_candidate(&mut child, Duration::from_millis(250)),
diff --git a/products/maka/cli/src/deployment/diagnostics.rs b/products/maka/cli/src/deployment/diagnostics.rs
index 9b10233876..d36fd75930 100644
--- a/products/maka/cli/src/deployment/diagnostics.rs
+++ b/products/maka/cli/src/deployment/diagnostics.rs
@@ -19,7 +19,9 @@
//! Failure-only, read-only queries isolated from the Host's execution lifetime.
-use super::{Deployment, HostError, logs, service};
+use super::{Deployment, HostError, service};
+use base64::Engine;
+use maka_host_runtime::logs::HostLogPage;
use maka_process::{Command, pipe};
use serde::Deserialize;
use std::{io, time::Duration};
@@ -79,7 +81,7 @@ async fn query(
&deployment.root_id,
]);
// Only status/logs: terminating this worker cannot cancel accepted Host work.
- // Its process group/Job also owns launchctl, systemctl or journalctl children.
+ // Its process group/Job also owns launchctl or systemctl children.
let bytes = read_only_worker(command, deadline).await?;
match query {
Query::Status => {
@@ -93,8 +95,11 @@ async fn query(
Ok(serde_json::to_string(&supervisor)?)
}
Query::Logs => match serde_json::from_slice(&bytes)? {
- logs::Output::NotCaptured => Ok("No service log captured".into()),
- logs::Output::Tail { text, .. } => Ok(text),
+ HostLogPage::Missing => Ok("No Host log captured".into()),
+ HostLogPage::Data { bytes, .. } => {
+ let raw = base64::engine::general_purpose::STANDARD.decode(bytes)?;
+ Ok(String::from_utf8_lossy(&raw).into_owned())
+ }
},
}
}
diff --git a/products/maka/cli/src/deployment/entry.rs b/products/maka/cli/src/deployment/entry.rs
index 5f4085bf44..d8d5b62911 100644
--- a/products/maka/cli/src/deployment/entry.rs
+++ b/products/maka/cli/src/deployment/entry.rs
@@ -29,10 +29,14 @@ pub(crate) struct ServiceRun {
impl ServiceRun {
pub async fn run(self) -> Result<(), HostError> {
+ let source = maka_host_runtime::logs::Source::for_root(
+ &self.root_id.0,
+ &maka_event_log::root::RootNamespaces::for_current_account()?,
+ )?;
+ crate::logging::capture(source.open_capture()?)?;
let directory = directory(&self.root_id.0)?;
#[cfg(windows)]
{
- crate::windows::service_stderr(&directory)?;
crate::windows::own_process_tree()?;
}
let store::Installation::Installed(deployment) = store::read(&directory).await? else {
diff --git a/products/maka/cli/src/deployment/local.rs b/products/maka/cli/src/deployment/local.rs
index 8a170f34cd..5c3a4fcd6d 100644
--- a/products/maka/cli/src/deployment/local.rs
+++ b/products/maka/cli/src/deployment/local.rs
@@ -143,6 +143,8 @@ async fn connect_inner(
return Err("State Root changed before launch".into());
}
let canonical = owner.canonical_path().to_owned();
+ let capture =
+ maka_host_runtime::logs::Source::from_owner(&owner)?.open_capture()?;
drop(owner);
child = Some(
detached::spawn(
@@ -160,6 +162,7 @@ async fn connect_inner(
"--initial-connection-timeout-ms",
"30000",
],
+ Some(capture),
)
.await?,
);
diff --git a/products/maka/cli/src/deployment/logs.rs b/products/maka/cli/src/deployment/logs.rs
index 82a4bf81a6..859db0877a 100644
--- a/products/maka/cli/src/deployment/logs.rs
+++ b/products/maka/cli/src/deployment/logs.rs
@@ -17,202 +17,70 @@
* under the License.
*/
-use super::{Deployment, Mode, RootId, directory, store};
+use super::RootId;
use clap::Args;
-use maka_host_runtime::HostError;
-use serde::{Deserialize, Serialize};
-
-const LIMIT: usize = 48 * 1024;
+use maka_event_log::root::RootNamespaces;
+use maka_host_runtime::{
+ HostError,
+ logs::{HostLogCursor, HostLogPosition, HostLogRequest, Source},
+};
+use std::{io::Write, sync::Arc};
+use tokio::{io::AsyncWriteExt, sync::Mutex};
+use tokio_util::sync::CancellationToken;
#[derive(Args)]
pub(crate) struct Logs {
#[arg(long)]
root_id: RootId,
-}
-
-#[derive(Deserialize, Serialize)]
-#[serde(
- tag = "kind",
- rename_all = "snake_case",
- rename_all_fields = "camelCase"
-)]
-pub(super) enum Output {
- NotCaptured,
- Tail {
- source: Source,
- text: String,
- byte_truncated: bool,
- },
-}
-
-#[derive(Deserialize, Serialize)]
-#[serde(
- tag = "kind",
- rename_all = "snake_case",
- rename_all_fields = "camelCase"
-)]
-pub(super) enum Source {
- #[cfg(target_os = "linux")]
- Journal { entry_limit: usize },
- #[cfg(any(target_os = "macos", windows))]
- Stderr,
+ #[arg(long)]
+ pub(crate) follow: bool,
+ #[arg(long)]
+ after: Option<HostLogCursor>,
}
impl Logs {
pub async fn run(self) -> Result<(), HostError> {
- let directory = directory(&self.root_id.0)?;
- let store::Installation::Installed(deployment) = store::read(&directory).await? else {
- return Err("State Root has no installed deployment".into());
- };
- if deployment.root_id != self.root_id.0 {
- return Err("deployment root identity differs".into());
- }
- deployment.validate_record(&directory)?;
- let output = if deployment.mode == Mode::OnDemand {
- Output::NotCaptured
- } else {
- read(&deployment).await?
- };
- println!("{}", serde_json::to_string(&output)?);
- Ok(())
- }
-}
-
-#[cfg(any(target_os = "macos", windows))]
-async fn read(deployment: &Deployment) -> Result<Output, HostError> {
- let directory = directory(&deployment.root_id)?;
- tokio::task::spawn_blocking(move || {
- use std::io::{Read, Seek, SeekFrom};
- let path = directory.join("host.stderr.log");
- #[cfg(target_os = "macos")]
- let opened = {
- use std::os::unix::fs::OpenOptionsExt;
- std::fs::OpenOptions::new()
- .read(true)
- .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
- .open(&path)
+ let source = Source::for_root(&self.root_id.0, &RootNamespaces::for_current_account()?)?;
+ let request = HostLogRequest {
+ position: match self.after {
+ Some(cursor) => HostLogPosition::After { cursor },
+ None => HostLogPosition::Tail,
+ },
};
- #[cfg(windows)]
- let opened = maka_event_log::root::windows::open_nofollow(&path, false);
- let mut file = match opened {
- Ok(file) => file,
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
- return Ok(Output::NotCaptured);
+ if self.follow {
+ let cancel = CancellationToken::new();
+ let _signals = crate::signals::watch(cancel.clone())?;
+ let output =
+ maka_transport::stdio::Output::open("host-logs-stdout", std::io::stdout())?;
+ let writer = Arc::new(Mutex::new(output.writer));
+ let result = source
+ .watch(request, cancel.clone(), |page| {
+ let writer = writer.clone();
+ async move {
+ let mut bytes = serde_json::to_vec(&page)?;
+ bytes.push(b'\n');
+ writer.lock().await.write_all(&bytes).await
+ }
+ })
+ .await;
+ // Source::watch selects cancellation while an emit is backpressured.
+ // Its dropped future releases this last bounded writer; the stdio
+ // thread owns no Root lease and need not drain a full pipe on cancel.
+ drop(writer);
+ result?;
+ if !cancel.is_cancelled() {
+ tokio::select! {
+ _ = cancel.cancelled() => {},
+ done = output.done => done.map_err(std::io::Error::other)??,
+ }
}
- Err(error) => return Err(error.into()),
- };
- let metadata = file.metadata()?;
- if !metadata.is_file() {
- return Err("service log is not a regular file".into());
- }
- #[cfg(target_os = "macos")]
- {
- use std::os::unix::fs::MetadataExt;
- if metadata.uid() != unsafe { libc::geteuid() }
- || metadata.mode() & 0o077 != 0
- || metadata.nlink() != 1
- {
- return Err("service log is not private and singly linked".into());
- }
- }
- #[cfg(windows)]
- {
- use maka_event_log::root::windows::{file_identity, validate_private};
- validate_private(&file)?;
- if file_identity(&file)?.links != 1 {
- return Err("service log is not singly linked".into());
- }
- }
- // Freeze the end offset; an active writer must not make this a follow.
- let start = metadata.len().saturating_sub(LIMIT as u64);
- file.seek(SeekFrom::Start(start))?;
- let mut bytes = Vec::with_capacity(LIMIT);
- file.take(metadata.len() - start).read_to_end(&mut bytes)?;
- Ok(Output::Tail {
- source: Source::Stderr,
- text: String::from_utf8_lossy(&bytes).into_owned(),
- byte_truncated: start != 0,
- })
- })
- .await?
-}
-
-#[cfg(target_os = "linux")]
-async fn read(deployment: &Deployment) -> Result<Output, HostError> {
- use std::{process::Stdio, time::Duration};
- const ENTRIES: usize = 200;
- let mut child = tokio::process::Command::new("journalctl")
- .args([
- "--user",
- "--no-pager",
- "--quiet",
- "--all",
- "--output=short-iso-precise",
- ])
- .arg(format!(
- "--user-unit=org.apache.maka.host.{}.service",
- deployment.root_id
- ))
- .arg(format!("--lines={ENTRIES}"))
- .stdin(Stdio::null())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .kill_on_drop(true)
- .spawn()?;
- let stdout = child.stdout.take().ok_or("journal stdout unavailable")?;
- let stderr = child.stderr.take().ok_or("journal stderr unavailable")?;
- let result = tokio::time::timeout(Duration::from_secs(10), async {
- tokio::try_join!(tail(stdout, LIMIT), tail(stderr, 2048), child.wait())
- })
- .await;
- let (stdout, stderr, status) = match result {
- Ok(Ok(output)) => output,
- result => {
- // Reap the query even on timeout or pipe failure; no pager is spawned.
- let _ = child.kill().await;
- return Err(match result {
- Err(error) => error.into(),
- Ok(Err(error)) => error.into(),
- Ok(Ok(_)) => unreachable!(),
- });
- }
- };
- if !status.success() {
- return Err(format!(
- "journal query failed ({status}): {}",
- String::from_utf8_lossy(&stderr.0)
- )
- .into());
- }
- Ok(Output::Tail {
- source: Source::Journal {
- entry_limit: ENTRIES,
- },
- text: String::from_utf8_lossy(&stdout.0).into_owned(),
- byte_truncated: stdout.1,
- })
-}
-
-#[cfg(target_os = "linux")]
-async fn tail(
- mut input: impl tokio::io::AsyncRead + Unpin,
- limit: usize,
-) -> std::io::Result<(Vec<u8>, bool)> {
- use tokio::io::AsyncReadExt;
- let mut bytes = std::collections::VecDeque::with_capacity(limit);
- let mut buffer = [0; 8192];
- let mut truncated = false;
- loop {
- let read = input.read(&mut buffer).await?;
- if read == 0 {
- break;
- }
- let overflow = (bytes.len() + read).saturating_sub(limit);
- if overflow != 0 {
- truncated = true;
- bytes.drain(..overflow.min(bytes.len()));
+ } else {
+ let page = source.read(request).await?;
+ let mut stdout = std::io::stdout().lock();
+ serde_json::to_writer(&mut stdout, &page)?;
+ stdout.write_all(b"\n")?;
+ stdout.flush()?;
}
- bytes.extend(&buffer[read.saturating_sub(limit)..read]);
+ Ok(())
}
- Ok((bytes.into(), truncated))
}
diff --git a/products/maka/cli/src/deployment/service/macos.rs b/products/maka/cli/src/deployment/service/macos.rs
index cfe7aed21b..0ceebcd195 100644
--- a/products/maka/cli/src/deployment/service/macos.rs
+++ b/products/maka/cli/src/deployment/service/macos.rs
@@ -29,6 +29,7 @@ pub(super) struct Service {
target: String,
path: PathBuf,
definition: String,
+ capture: Option<maka_host_runtime::logs::Source>,
}
struct Instance {
@@ -47,11 +48,18 @@ impl Service {
let args = arguments(deployment, role)?
.map(|arg| format!("<string>{}</string>", xml(arg)))
.join("");
- let log = super::super::directory(&deployment.root_id)?.join(if role == Role::Host {
- "host.stderr.log"
+ let capture = if role == Role::Host {
+ Some(maka_host_runtime::logs::Source::for_root(
+ &deployment.root_id,
+ &maka_event_log::root::RootNamespaces::for_current_account()?,
+ )?)
} else {
- "update.stderr.log"
- });
+ None
+ };
+ let log = match &capture {
+ Some(source) => source.path().to_owned(),
+ None => super::super::directory(&deployment.root_id)?.join("update.stderr.log"),
+ };
let log = xml(log.to_str().ok_or("service log path must be UTF-8")?);
let definition = if role == Role::Updater {
format!(
@@ -68,6 +76,7 @@ impl Service {
label,
path,
definition,
+ capture,
})
}
@@ -79,6 +88,9 @@ impl Service {
pub fn prepare(&self) -> Result<(), HostError> {
self.stop()?;
+ if let Some(source) = &self.capture {
+ drop(source.open_capture()?);
+ }
publish(&self.path, &self.definition)?;
checked(command("/bin/launchctl", &["enable", &self.target])?)?;
checked(command(
diff --git a/products/maka/cli/src/logging.rs b/products/maka/cli/src/logging.rs
index 1ffc73d4ea..9f591e59e0 100644
--- a/products/maka/cli/src/logging.rs
+++ b/products/maka/cli/src/logging.rs
@@ -27,11 +27,94 @@ pub(super) fn initialize() -> Result<(), maka_host_runtime::HostError> {
.try_init()
}
+/// Dedicated Host processes keep stderr attached through final error reporting
+/// and runtime shutdown. The caller supplies an already validated append file.
+pub(super) fn capture(file: std::fs::File) -> std::io::Result<()> {
+ #[cfg(unix)]
+ {
+ use std::os::fd::{AsRawFd, IntoRawFd};
+ // A caller may have started with stderr closed, so opening the capture
+ // can itself allocate fd 2. Transfer that descriptor instead of closing
+ // it when File drops, and give it ordinary stdio inheritance semantics.
+ if file.as_raw_fd() == libc::STDERR_FILENO {
+ // SAFETY: the owned file is the live stderr descriptor.
+ if unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_SETFD, 0) } == -1 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let _ = file.into_raw_fd();
+ return Ok(());
+ }
+ // SAFETY: both descriptors are valid; dup2 leaves stderr owning its own
+ // reference after the original File is dropped.
+ if unsafe { libc::dup2(file.as_raw_fd(), libc::STDERR_FILENO) } == -1 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ }
+ #[cfg(windows)]
+ {
+ use std::os::windows::io::AsRawHandle;
+ use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, SetStdHandle};
+ // SetStdHandle borrows the handle. Statics are not dropped at shutdown;
+ // the OS closes it after the last process error has been written.
+ static STDERR: std::sync::OnceLock<std::io::Result<std::fs::File>> =
+ std::sync::OnceLock::new();
+ STDERR
+ .get_or_init(|| {
+ // SAFETY: STDERR retains the owned file until process exit.
+ if unsafe { SetStdHandle(STD_ERROR_HANDLE, file.as_raw_handle()) } == 0 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(file)
+ }
+ })
+ .as_ref()
+ .map(|_| ())
+ .map_err(|error| std::io::Error::new(error.kind(), error.to_string()))
+ }
+}
+
#[cfg(test)]
mod tests {
use sqlx::{ConnectOptions, Connection};
use std::{process::Command, time::Duration};
+ #[cfg(unix)]
+ #[test]
+ fn capture_keeps_the_file_open_when_stderr_started_closed() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("stderr.log");
+ let output = Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+ "logging::tests::closed_stderr_worker",
+ "--nocapture",
+ ])
+ .env("MAKA_LOGGING_CLOSED_STDERR", &path)
+ .output()
+ .unwrap();
+ assert!(output.status.success(), "{output:?}");
+ assert_eq!(
+ std::fs::read(path).unwrap(),
+ b"captured after closed stderr\n"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn closed_stderr_worker() {
+ use std::os::fd::AsRawFd;
+ let Some(path) = std::env::var_os("MAKA_LOGGING_CLOSED_STDERR") else {
+ return;
+ };
+ // SAFETY: this dedicated subprocess owns its standard descriptors.
+ assert_eq!(unsafe { libc::close(libc::STDERR_FILENO) }, 0);
+ let file = std::fs::File::create(path).unwrap();
+ assert_eq!(file.as_raw_fd(), libc::STDERR_FILENO);
+ super::capture(file).unwrap();
+ eprintln!("captured after closed stderr");
+ }
+
#[test]
fn sqlx_warnings_reach_stderr_without_debug_queries() {
for (mode, expected) in [("slow", true), ("ordinary", false)] {
diff --git a/products/maka/cli/src/serve.rs b/products/maka/cli/src/serve.rs
index 8fd774cff7..d21189d7df 100644
--- a/products/maka/cli/src/serve.rs
+++ b/products/maka/cli/src/serve.rs
@@ -57,6 +57,11 @@ pub(super) async fn run(
if expected_root.is_some_and(|expected| expected != owner.root_id()) {
return Err("service State Root identity changed".into());
}
+ if expected_root.is_none() {
+ let logs = maka_host_runtime::logs::Source::from_owner(&owner)?;
+ eprintln!("Host diagnostics: {}", logs.path().display());
+ crate::logging::capture(logs.open_capture()?)?;
+ }
let deployment = crate::deployment::admit(&owner, crate::deployment::Mode::Supervised).await?;
if expected_root.is_some() && deployment.is_none() {
return Err("service State Root must have a supervised deployment".into());
diff --git a/products/maka/cli/src/windows.rs b/products/maka/cli/src/windows.rs
index fc668ddbab..fd64ccb6d6 100644
--- a/products/maka/cli/src/windows.rs
+++ b/products/maka/cli/src/windows.rs
@@ -37,76 +37,6 @@ use windows_sys::Win32::System::{
// the Host exits, terminating descendants without killing the Host during Drop.
static OWNER: OnceLock<io::Result<OwnedHandle>> = OnceLock::new();
-// SetStdHandle borrows the file handle. Retain it until process exit, including
-// final error reporting and Tokio's shutdown of accepted blocking work.
-static SERVICE_STDERR: OnceLock<io::Result<std::fs::File>> = OnceLock::new();
-
-pub(super) fn service_stderr(directory: &std::path::Path) -> io::Result<()> {
- SERVICE_STDERR
- .get_or_init(|| open_service_stderr(directory))
- .as_ref()
- .map(|_| ())
- .map_err(|error| io::Error::new(error.kind(), error.to_string()))
-}
-
-fn open_service_stderr(directory: &std::path::Path) -> io::Result<std::fs::File> {
- use maka_event_log::root::windows::{
- PrivateSecurity, file_identity, open_nofollow, validate_private,
- };
- use std::os::windows::{ffi::OsStrExt, fs::MetadataExt};
- use windows_sys::Win32::{
- Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE},
- Storage::FileSystem::{
- CreateFileW, FILE_APPEND_DATA, FILE_ATTRIBUTE_REPARSE_POINT,
- FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
- OPEN_ALWAYS,
- },
- System::Console::{STD_ERROR_HANDLE, SetStdHandle},
- };
-
- let parent = open_nofollow(directory, false)?;
- validate_private(&parent)?;
- if !parent.metadata()?.is_dir() {
- return Err(io::Error::other("service deployment is not a directory"));
- }
- let path = directory.join("host.stderr.log");
- let name: Vec<_> = path.as_os_str().encode_wide().chain(Some(0)).collect();
- let security = PrivateSecurity::current_account()?;
- // SAFETY: name and private descriptor live through the call. Append-only
- // writes preserve concurrent startup diagnostics without following reparse points.
- let handle = unsafe {
- CreateFileW(
- name.as_ptr(),
- GENERIC_READ | FILE_APPEND_DATA,
- FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
- &security.attributes(),
- OPEN_ALWAYS,
- FILE_FLAG_OPEN_REPARSE_POINT,
- ptr::null_mut(),
- )
- };
- if handle == INVALID_HANDLE_VALUE {
- return Err(io::Error::last_os_error());
- }
- // SAFETY: CreateFileW returned one owned file handle.
- let file = unsafe { std::fs::File::from_raw_handle(handle) };
- let metadata = file.metadata()?;
- if !metadata.is_file()
- || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
- || file_identity(&file)?.links != 1
- {
- return Err(io::Error::other(
- "service log is not a regular, singly linked file",
- ));
- }
- validate_private(&file)?;
- // SAFETY: SERVICE_STDERR owns the returned file until the process exits.
- if unsafe { SetStdHandle(STD_ERROR_HANDLE, file.as_raw_handle()) } == 0 {
- return Err(io::Error::last_os_error());
- }
- Ok(file)
-}
-
pub(super) fn own_process_tree() -> io::Result<()> {
OWNER
.get_or_init(create)
diff --git a/products/maka/cli/tests/integration/candidate.rs b/products/maka/cli/tests/integration/candidate.rs
index 34f99e0340..59ec2756e0 100644
--- a/products/maka/cli/tests/integration/candidate.rs
+++ b/products/maka/cli/tests/integration/candidate.rs
@@ -358,6 +358,7 @@ impl Drop for CandidateFixture {
std::thread::sleep(Duration::from_millis(20));
}
let _ = fs::remove_file(&self.registration);
+ let _ = fs::remove_file(control.join("host.stderr.log"));
let deployment = namespaces
.ownership
.parent()
diff --git a/products/maka/cli/tests/integration/candidate/startup.rs b/products/maka/cli/tests/integration/candidate/startup.rs
index d313a5134a..70cb9af479 100644
--- a/products/maka/cli/tests/integration/candidate/startup.rs
+++ b/products/maka/cli/tests/integration/candidate/startup.rs
@@ -19,6 +19,51 @@
use super::*;
+#[test]
+fn host_logs_reads_captured_stderr_without_an_installed_deployment() {
+ use base64::Engine;
+
+ let directory = tempfile::tempdir().unwrap();
+ let fixture = CandidateFixture::new(directory.path().join("root"));
+ let path = fixture
+ .registration
+ .parent()
+ .unwrap()
+ .join("host.stderr.log");
+ let mut options = fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ let mut capture = options.open(path).unwrap();
+ capture.write_all(b"pre-Ready host failure\n\xff").unwrap();
+ drop(capture);
+
+ let output = Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args(["host", "logs", "--root-id", &fixture.root_id])
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "uninstalled Root's captured stderr must remain readable: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let page: Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(page["kind"], "data");
+ assert_eq!(
+ base64::engine::general_purpose::STANDARD
+ .decode(page["bytes"].as_str().unwrap())
+ .unwrap(),
+ b"pre-Ready host failure\n\xff"
+ );
+ assert_eq!(page["reset"], Value::Null);
+ assert_eq!(page["truncated"], false);
+ assert_eq!(page["hasMore"], false);
+ assert!(!page["cursor"].as_str().unwrap().is_empty());
+}
+
fn start(directory: &Path) -> CandidateFixture {
let mut fixture = CandidateFixture::new(directory.join("root"));
fixture.child = Some(
@@ -106,3 +151,279 @@ fn candidate_owner_eof_without_an_accepted_client_drains_startup() {
.unwrap(),
);
}
+
+#[test]
+fn detached_candidate_captures_pre_ready_and_released_owner_failures() {
+ use base64::Engine;
+ use futures_util::FutureExt;
+ use maka_host_runtime::logs::Source;
+ use tokio::io::AsyncWriteExt;
+
+ let directory = tempfile::tempdir().unwrap();
+ let fixture = CandidateFixture::new(directory.path().join("root"));
+ let runtime = tokio::runtime::Runtime::new().unwrap();
+ runtime.block_on(async {
+ for released in [false, true] {
+ let owner = RootOwner::open(
+ &fixture.root,
+ &RootNamespaces::for_current_account().unwrap(),
+ )
+ .unwrap();
+ let source = Source::from_owner(&owner).unwrap();
+ let capture = source.open_capture().unwrap();
+ drop(owner);
+ let expected_id = if released {
+ fixture.root_id.as_str()
+ } else {
+ "wrong-root"
+ };
+ let mut child = maka_process::detached::spawn(
+ Path::new(env!("CARGO_BIN_EXE_maka")),
+ &[
+ "host",
+ "candidate",
+ "--root",
+ fixture.root.to_str().unwrap(),
+ "--expected-root-id",
+ expected_id,
+ "--startup-attempt-id",
+ &uuid::Uuid::new_v4().to_string(),
+ "--owner-stdin",
+ "--initial-connection-timeout-ms",
+ "30000",
+ ],
+ Some(capture),
+ )
+ .await
+ .unwrap();
+ let mut original_registration = None;
+ let outcome = std::panic::AssertUnwindSafe(async {
+ tokio::time::timeout(Duration::from_secs(15), async {
+ if released {
+ loop {
+ if fixture.registration.exists() {
+ break;
+ }
+ assert!(
+ child.try_wait().unwrap().is_none(),
+ "candidate exited before Ready"
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ let found = discovery::read(&fixture.root).unwrap();
+ original_registration = Some(fs::read(&fixture.registration).unwrap());
+ let mut client = connect(&fixture.root).await;
+ let mut stdin = child.stdin.take().unwrap();
+ stdin
+ .write_all(b"{\"kind\":\"runtime-host-launch-owner-release\"}\n")
+ .await
+ .unwrap();
+ drop(stdin);
+ assert!(
+ !Client::status(&found.endpoint, Some(client.epoch()))
+ .await
+ .unwrap()
+ .closing
+ );
+ // A real shutdown failure after launcher release must still reach stderr.
+ fs::write(&fixture.registration, b"not a registration").unwrap();
+ client.close().await.unwrap();
+ assert!(
+ Client::retire_with_options(
+ &found.endpoint,
+ found.host_epoch,
+ false,
+ false
+ )
+ .await
+ .unwrap()
+ );
+ }
+ let status = child.wait().await.unwrap();
+ assert!(
+ !status.success(),
+ "injected startup/shutdown failure was not reported"
+ );
+ })
+ .await
+ .expect("captured candidate did not settle");
+ let output = Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args(["host", "logs", "--root-id", &fixture.root_id])
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let page: Value = serde_json::from_slice(&output.stdout).unwrap();
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(page["bytes"].as_str().unwrap())
+ .unwrap();
+ let log = String::from_utf8_lossy(&bytes);
+ assert!(
+ log.contains(if released {
+ "could not remove Host registration"
+ } else {
+ "candidate State Root identity mismatch"
+ }),
+ "{log}"
+ );
+ })
+ .catch_unwind()
+ .await;
+ // Reap the exact spawned child even when an assertion or readiness wait fails.
+ child.start_kill().unwrap();
+ tokio::time::timeout(Duration::from_secs(5), child.wait())
+ .await
+ .unwrap()
+ .unwrap();
+ if let Some(bytes) = original_registration {
+ fs::write(&fixture.registration, bytes).unwrap();
+ }
+ if let Err(panic) = outcome {
+ std::panic::resume_unwind(panic);
+ }
+ }
+ });
+}
+
+#[cfg(unix)]
+#[test]
+fn host_logs_follow_streams_uninstalled_root_and_exits_with_a_full_stdout_pipe() {
+ use base64::Engine;
+ use std::{
+ io::Read,
+ os::fd::{AsRawFd, FromRawFd},
+ };
+
+ let directory = tempfile::tempdir().unwrap();
+ let mut fixture = CandidateFixture::new(directory.path().join("root"));
+ let mut descriptors = [-1; 2];
+ // This test owns both ends; retaining the writer permits observing actual backpressure.
+ assert_eq!(unsafe { libc::pipe(descriptors.as_mut_ptr()) }, 0);
+ for descriptor in descriptors {
+ assert_eq!(
+ unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) },
+ 0
+ );
+ }
+ let mut reader = unsafe { fs::File::from_raw_fd(descriptors[0]) };
+ let writer = unsafe { fs::File::from_raw_fd(descriptors[1]) };
+ let flags = unsafe { libc::fcntl(reader.as_raw_fd(), libc::F_GETFL) };
+ assert!(flags >= 0);
+ assert_eq!(
+ unsafe { libc::fcntl(reader.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) },
+ 0
+ );
+ #[cfg(target_os = "linux")]
+ assert!(unsafe { libc::fcntl(writer.as_raw_fd(), libc::F_SETPIPE_SZ, 4096) } >= 4096);
+ fixture.child = Some(
+ Command::new(env!("CARGO_BIN_EXE_maka"))
+ .args([
+ "--timeout-ms",
+ "1",
+ "host",
+ "logs",
+ "--root-id",
+ &fixture.root_id,
+ "--follow",
+ ])
+ .stdout(Stdio::from(writer.try_clone().unwrap()))
+ .stderr(Stdio::inherit())
+ .spawn()
+ .unwrap(),
+ );
+
+ fn page(reader: &mut fs::File, fixture: &mut CandidateFixture) -> Value {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let mut bytes = Vec::new();
+ loop {
+ let mut byte = [0];
+ match reader.read(&mut byte) {
+ Ok(1) if byte[0] == b'\n' => return serde_json::from_slice(&bytes).unwrap(),
+ Ok(1) => bytes.push(byte[0]),
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ assert!(
+ fixture
+ .child
+ .as_mut()
+ .unwrap()
+ .try_wait()
+ .unwrap()
+ .is_none(),
+ "follow exited before its next page"
+ );
+ assert!(Instant::now() < deadline, "follow page timed out");
+ let mut poll = libc::pollfd {
+ fd: reader.as_raw_fd(),
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ assert!(unsafe { libc::poll(&mut poll, 1, 100) } >= 0);
+ }
+ other => panic!("unexpected follow read: {other:?}"),
+ }
+ }
+ }
+
+ assert_eq!(
+ page(&mut reader, &mut fixture),
+ serde_json::json!({"kind":"missing"})
+ );
+ let owner = RootOwner::open(
+ &fixture.root,
+ &RootNamespaces::for_current_account().unwrap(),
+ )
+ .unwrap();
+ let source = maka_host_runtime::logs::Source::from_owner(&owner).unwrap();
+ let mut capture = source.open_capture().unwrap();
+ drop(owner);
+ let initial = page(&mut reader, &mut fixture);
+ assert_eq!(initial["kind"], "data");
+ assert_eq!(initial["bytes"], "");
+ capture.write_all(b"after creation\n\xff").unwrap();
+ let appended = page(&mut reader, &mut fixture);
+ assert_eq!(
+ base64::engine::general_purpose::STANDARD
+ .decode(appended["bytes"].as_str().unwrap())
+ .unwrap(),
+ b"after creation\n\xff"
+ );
+ assert_eq!(appended["reset"], Value::Null);
+
+ // More than the pipe, one blocking write, and the bounded async bridge can hold.
+ capture.write_all(&vec![b'x'; 1024 * 1024]).unwrap();
+ let deadline = Instant::now() + Duration::from_secs(10);
+ loop {
+ let mut poll = libc::pollfd {
+ fd: writer.as_raw_fd(),
+ events: libc::POLLOUT,
+ revents: 0,
+ };
+ assert!(unsafe { libc::poll(&mut poll, 1, 0) } >= 0);
+ if poll.revents & libc::POLLOUT == 0 {
+ break;
+ }
+ assert!(
+ fixture
+ .child
+ .as_mut()
+ .unwrap()
+ .try_wait()
+ .unwrap()
+ .is_none()
+ );
+ assert!(
+ Instant::now() < deadline,
+ "follow never filled its stdout pipe"
+ );
+ std::thread::park_timeout(Duration::from_millis(2));
+ }
+ // The exact owned child must service cancellation without waiting for this reader.
+ assert_eq!(
+ unsafe { libc::kill(fixture.child.as_ref().unwrap().id() as i32, libc::SIGTERM) },
+ 0
+ );
+ assert!(fixture.wait_for_exit().success());
+}
diff --git a/products/maka/cli/tests/integration/terminal/bundled_product.rs b/products/maka/cli/tests/integration/terminal/bundled_product.rs
index 49af4e4d56..02792706dc 100644
--- a/products/maka/cli/tests/integration/terminal/bundled_product.rs
+++ b/products/maka/cli/tests/integration/terminal/bundled_product.rs
@@ -24,6 +24,7 @@ use serde_json::json;
use unicode_width::UnicodeWidthStr;
mod chat;
+mod host_logs;
#[track_caller]
fn click(tui: &mut Pty, label: &str) {
diff --git a/products/maka/cli/tests/integration/terminal/bundled_product/host_logs.rs b/products/maka/cli/tests/integration/terminal/bundled_product/host_logs.rs
new file mode 100644
index 0000000000..05a01975b0
--- /dev/null
+++ b/products/maka/cli/tests/integration/terminal/bundled_product/host_logs.rs
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_agent_api::session::SessionConfiguration;
+use maka_event_log::{EventLog, usage::Query};
+use maka_host_runtime::logs::Source;
+use std::sync::Arc;
+
+#[test]
+#[ignore = "real default product Host log grants, no-session Diagnostics and live follow in a native PTY"]
+fn default_product_host_logs_without_session_read_follow_and_release_roots() {
+ let directory = tempfile::tempdir().unwrap();
+ let workspace = directory.path().join("workspace");
+ std::fs::create_dir(&workspace).unwrap();
+ let host = directory.path().join("host");
+ let host_guard = HostRootGuard::new(&host);
+ let ui = directory.path().join("ui");
+ let namespaces = RootNamespaces::for_current_account().unwrap();
+ let owner = RootOwner::create(&host, &namespaces).unwrap();
+ let source = Source::from_owner(&owner).unwrap();
+ let mut capture = source.open_capture().unwrap();
+ drop(owner);
+ let tag = uuid::Uuid::new_v4().simple().to_string();
+ let first = format!("HOST-LOG-{}-ONE", &tag[..8]);
+ let second = format!("HOST-LOG-{}-TWO 中文", &tag[..8]);
+ writeln!(capture, "{first}").unwrap();
+ capture.flush().unwrap();
+ let configuration = directory.path().join("installation.json");
+ std::fs::write(
+ &configuration,
+ serde_json::to_vec(&json!({
+ "presentation": "tui", "locale": "en", "scheme": "dark",
+ "hostRoot": host,
+ "agent": {
+ "workspaceRoots": [{"label": "Workspace", "path": workspace}],
+ "tools": [],
+ "permissions": {"access": "read_only", "requestPermissions": true}
+ }
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+ let mut tui = Pty::spawn(&[
+ "application",
+ "launch",
+ "--root",
+ ui.to_str().unwrap(),
+ "--configuration",
+ configuration.to_str().unwrap(),
+ ]);
+ tui.wait_until_with_timeout(
+ |screen| screen.contains("Settings") && screen.contains("Start a task."),
+ Duration::from_secs(60),
+ );
+ assert!(!tui.screen.snapshot().unwrap().screen.contains("Message…"));
+ tui.send(b"\x0b");
+ tui.wait_for("Close commands");
+ tui.send(b"Diagnostics");
+ tui.wait_for("› Diagnostics");
+ tui.send(b"\r");
+ tui.wait_for("Host logs");
+ tui.wait_for(&first);
+ settings_evidence(&tui, "host-logs-read");
+ click(&mut tui, "Follow");
+ tui.wait_for("Stop");
+ writeln!(capture, "{second}").unwrap();
+ capture.flush().unwrap();
+ tui.wait_for(&second);
+ assert!(tui.screen.snapshot().unwrap().screen.contains(&first));
+ settings_evidence(&tui, "host-logs-follow");
+ click(&mut tui, "Stop");
+ tui.wait_for("Follow");
+ click(&mut tui, "Close");
+ tui.wait_until(|screen| !screen.contains(&first) && !screen.contains("Follow"));
+ tui.send(b"\x11");
+ tui.finish();
+ assert!(!String::from_utf8_lossy(&tui.output).contains("invalid UI activation"));
+ assert!(!String::from_utf8_lossy(&tui.output).contains("invalid UI batch"));
+ drop(capture);
+ drop(RootOwner::open(&ui, &namespaces).unwrap());
+ host_guard.stop();
+ let owner = Arc::new(RootOwner::open(&host, &namespaces).unwrap());
+ tokio::runtime::Runtime::new().unwrap().block_on(async {
+ let store = EventLog::for_root(owner).await.unwrap();
+ assert!(
+ store
+ .list_sessions::<SessionConfiguration>(None, None, 1)
+ .await
+ .unwrap()
+ .sessions
+ .is_empty(),
+ "Host diagnostics must not create a conversation"
+ );
+ assert!(
+ store
+ .model_attempts(
+ Query {
+ from: 0.0,
+ to: 253402300799999.0,
+ session_id: None,
+ through: None,
+ },
+ 0,
+ 1
+ )
+ .await
+ .unwrap()
+ .attempts
+ .is_empty(),
+ "Host diagnostics must not invoke a model"
+ );
+ store.close().await.unwrap();
+ });
+ drop(RootOwner::open(&host, &namespaces).unwrap());
+}
diff --git a/tools/schema/src/lib.rs b/tools/schema/src/lib.rs
index f808bc6d57..44ea8eace7 100644
--- a/tools/schema/src/lib.rs
+++ b/tools/schema/src/lib.rs
@@ -33,6 +33,8 @@ pub fn catalog() -> Value {
use composition::PresentationTarget;
let mut g = SchemaGenerator::default();
let roots = vec![
+ root::<maka_host_runtime::logs::HostLogRequest>(&mut g),
+ root::<maka_host_runtime::logs::HostLogPage>(&mut g),
root::<maka_ui_authoring::contract::Command>(&mut g),
root::<maka_ui_authoring::contract::Claim>(&mut g),
root::<maka_ui_authoring::contract::State>(&mut g),
@@ -417,6 +419,8 @@ pub fn catalog() -> Value {
"workspaceWeb": maka_web_api::contract(),
"workspaceJev": maka_workspace_api::jev::contract(),
});
+ contracts["hostLogs"] = serde_json::to_value(maka_host_runtime::application::logs::contract())
+ .expect("Host logs contract");
contracts["workspaceComputer"] =
serde_json::to_value(maka_workspace_api::computer::contract()).expect("Computer contract");
contracts["settingsSetup"] =
@@ -466,6 +470,8 @@ pub fn catalog() -> Value {
"settings": maka_settings_native::native(),
"settingsAuthentication": maka_settings_native::native_authentication(),
});
+ native["hostLogs"] = serde_json::to_value(maka_host_runtime::application::logs::native())
+ .expect("Host logs native");
native["settingsSetup"] =
serde_json::to_value(maka_settings_native::native_setup()).expect("setup native");
json!({
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org