5 Commits
Author SHA1 Message Date
jpmschweitzerandClaude c94ad2c05b restructure licenses.yaml: self + runtime deps + dev deps
test / unit + widget + golden + a11y (push) Successful in 1m30s
test / integration_test (xvfb) (push) Failing after 1m14s
test / bundle smoke (xvfb 5s) (push) Failing after 1m3s
test / daemon subprocess + web WASM smoke (push) Successful in 1m42s
Three sections instead of one flat list. `self:` carries clide's own
MIT license (rendered first in the About screen so the user knows
what they're running before the dependency list). `dependencies:`
covers artefacts that actually ship in the binary — JetBrainsMono,
JosefinSans, yaml. `dev_dependencies:` tracks build-time tooling
(mocktail, alchemist, flutter_lints, lints, test) for audit
completeness without polluting the user-facing About panel.

Also backfilled the two root-package dev deps that were missed the
first pass: `lints: 5.0.0` and `test: 1.25.8` (root pubspec, not
app/). Root LICENSE mirrored into app/assets/LICENSE + declared as
a bundled asset so the About screen can read it at runtime (Flutter
can't reference paths above the package root).

D-042 updated to describe the three-section split and the dev-vs-
runtime distinction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-22 08:26:36 +02:00
jpmschweitzerandClaude 1b00d88a0d document bundled deps in licenses.yaml; record D-042
Every third-party artefact shipping in the clide binary — fonts,
Dart packages, native tools, bundled assets — now has an entry in
app/assets/licenses.yaml with name, kind, version, homepage, license
identifier, relative path to the bundled license text, and a
one-line purpose. The About screen (Tier 6) will render this file
verbatim. Seeded with the current set: JetBrainsMono, JosefinSans,
yaml, mocktail, alchemist, flutter_lints.

D-042 captures the two-step-commit rule (artefact + licenses.yaml
entry in the same changeset) alongside D-031's prefer-zero-deps
budget: preferring zero deps is the budget; licenses.yaml is the
visible consequence when the budget grows.

CLAUDE.md "Dependencies & supply chain" gains a matching guardrail
line. The per-dep license text files (OFL.txt for both fonts) are
declared as pubspec assets too so the About screen has something to
display, not just a manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-22 08:18:39 +02:00
jpmschweitzerandClaude c1ad33f1b0 bundle Josefin Sans as the default UI face at weight 300
Vendor the JosefinSans variable font (upright + italic, weight range
100-700), OFL licensed. _AppRoot installs it as the ambient
DefaultTextStyle at weight w300 (Light); ClideText inherits rather
than re-specifying the family, so Alchemist's Ahem injection keeps
goldens deterministic per D-024 while production renders the
intended face.

typography.dart gains clideUiFamily + clideUiFamilyFallback
alongside the existing mono constants; .gitignore learns about
Alchemist's failures/ diff output dir so local re-runs don't leak
into `git status`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-22 08:18:15 +02:00
jpmschweitzerandClaude 7bdeb9ab24 bundle JetBrains Mono for terminal panes
Vendor the 4 core weights (Regular, Italic, Bold, BoldItalic) of
JetBrains Mono v2.304 under app/assets/fonts/jetbrains_mono/, ~1 MB
total. OFL license checked in alongside. Declared as the
JetBrainsMono family in pubspec so Flutter bundles it automatically.

app/lib/widgets/src/typography.dart exposes clideMonoFamily plus a
platform-ordered fallback chain (SF Mono / Menlo / Monaco →
JetBrains Mono system install / Fira Code / Hack / DejaVu / Liberation →
Cascadia / Consolas → monospace) for web builds and test harnesses
that don't load asset fonts. Terminal widgets pick both up via a
single import.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-22 08:02:10 +02:00
jpmschweitzerandClaude 0412a2284a scaffold ptyc — C PTY-spawn helper (libc only)
One-shot helper that opens a PTY, forks a child, execvp's the given
argv with the slave as stdin/stdout/stderr, and hands the master fd
back to the caller over a unix socket via SCM_RIGHTS. Wire contract
is stdin JSON → stdout JSON + fd transfer per D-005; socket fd
defaults to 3 with PTYC_SOCK_FD override for language runtimes whose
subprocess machinery shuffles the low fd numbers.

Minimal JSON parser (no deps) scoped to the exact accepted shape.
Exec-failure pipe (CLOEXEC) reports child-side errors back without
zombies. Window size applied via TIOCSWINSZ before fork; child
becomes session leader + makes the slave its controlling TTY.

Root Makefile PTYX_PRESENT typo fixed → PTYC_PRESENT, and a
ptyc-test target added alongside ptyc-build / ptyc-clean. Eight
smoke tests pass (happy path, env replacement, cwd, window size,
bad argv, type errors, exec failure, unknown keys).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 22:27:20 +02:00
24 changed files with 1297 additions and 13 deletions
+5
View File
@@ -47,6 +47,11 @@ tools/ui/.serve.pid
coverage.*
/tmp/
# Alchemist golden-test failure artefacts (diff images written when a
# pixel-compare fails locally; the committed fixtures live under
# app/test/goldens/goldens/{ci,linux}/).
app/test/goldens/failures/
# -- pql per-repo state (clide dogfoods against itself; pql's index lands
# here when running queries locally). `pql.db` is also where the
# `tools/scripts/plan` stopgap writes decisions + tickets (D-040).
+45
View File
@@ -18,6 +18,51 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
### Added
- Josefin Sans bundled as `app/assets/fonts/josefin_sans/` as the
application UI face — variable-font pair (upright + italic, weight
range 100-700), OFL-licensed. Declared as the `JosefinSans` family
in `app/pubspec.yaml`. `_AppRoot` installs it as the ambient
`DefaultTextStyle` at weight `w300` (Light) per the project's
aesthetic direction; callers can still pass an explicit
`fontWeight` on `ClideText` to get bolder emphasis.
- JetBrains Mono bundled as `app/assets/fonts/jetbrains_mono/` —
Regular / Italic / Bold / BoldItalic weights (OFL-licensed,
license file checked in alongside). Declared as the `JetBrainsMono`
family in `app/pubspec.yaml`. `app/lib/widgets/src/typography.dart`
exposes `clideUiFamily` + `clideMonoFamily` plus platform-ordered
fallback chains for both faces, for web builds and harnesses that
don't load asset fonts.
- `app/assets/licenses.yaml` — canonical manifest of every bundled
third-party artefact (fonts today; Dart packages + native tools as
they land). Schema has name, kind, version, homepage, license,
`license_file` pointer, and a one-line purpose. Bundled alongside
the per-dep license texts. The About screen (Tier 6) will render
this file verbatim. Accompanied by
[`D-042`](decisions/tooling.md#d-042-bundled-dependencies-documented-in-licensesyaml):
adding a dep is a two-step commit (artefact + `licenses.yaml`
entry in the same changeset).
### Changed
- CLAUDE.md "Dependencies & supply chain" section gains the
"document every bundled dependency" rule, pointing at
`app/assets/licenses.yaml` and `D-042`.
- `ptyc/` — the C PTY-spawn helper, peer of `pql` per
[`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer).
One-shot, libc-only, ~400 LOC. Reads a JSON request on stdin
(`argv`, optional `cwd`/`env`/`cols`/`rows`), does
`posix_openpt` + `fork` + `execvp`, and hands the master fd back
to the caller over a unix socket via `SCM_RIGHTS`. Socket fd
defaults to 3; override via `PTYC_SOCK_FD` for language runtimes
that shuffle pipe fds through the low numbers (Python's
`subprocess` with `stdout=PIPE` does this). Exec-failure pipe
(CLOEXEC) reports child-side errors to the parent without leaking
zombies. Root Makefile gains `ptyc-test` target in addition to
`ptyc-build` / `ptyc-clean`.
- Migrated the `docs/ADRs/` content into `decisions/` as D/R records:
ADR 0001 → `D-001`, ADR 0002 → `R-002` (superseded by `D-005`), ADR
0003 → `D-003`, ADR 0004 → `D-004`, ADR 0005 → `D-005`, ADR 0006 →
+1
View File
@@ -51,6 +51,7 @@ See `docs/initial-plan.md` for the full tier definitions and acceptance criteria
## Dependencies & supply chain
- **Dart (core + app):** prefer-zero-deps. Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
- **Document every bundled dependency.** Every third-party artefact that ships in the clide binary — Dart packages, fonts, bundled assets, native supporter tools — is listed in [`app/assets/licenses.yaml`](app/assets/licenses.yaml) with name, kind, version, homepage, license, relative path to the bundled license text, and a one-line purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry in the same changeset. The About screen (Tier 6) renders the file verbatim; until then, the list being accurate is the contract. See [`D-042`](decisions/tooling.md#d-042-bundled-dependencies-documented-in-licensesyaml).
- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump.
- `pubspec.lock` is always committed.
- `make security` runs the Dart advisory review; `ci/security.sh` is the CI entry.
+11 -3
View File
@@ -132,19 +132,27 @@ endif
# -- ptyc (C supporter tool) --------------------------------------------
# Compiled alongside the main binary. Tiny, no deps beyond libc.
PTYX_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
.PHONY: ptyc-build
ptyc-build: ## Build the ptyc PTY-spawn helper.
ifeq ($(PTYX_PRESENT),yes)
ifeq ($(PTYC_PRESENT),yes)
$(MAKE) -C ptyc
else
@echo "(ptyc/ not scaffolded yet; skipping)"
endif
.PHONY: ptyc-test
ptyc-test: ## Run ptyc smoke tests (SCM_RIGHTS round-trip).
ifeq ($(PTYC_PRESENT),yes)
$(MAKE) -C ptyc test
else
@echo "(ptyc/ not scaffolded yet; skipping)"
endif
.PHONY: ptyc-clean
ptyc-clean: ## Clean ptyc build artefacts.
ifeq ($(PTYX_PRESENT),yes)
ifeq ($(PTYC_PRESENT),yes)
$(MAKE) -C ptyc clean
else
@echo "(ptyc/ not scaffolded yet; skipping)"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jeroen Schweitzer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010 The Josefin Sans Project Authors (https://github.com/ThomasJockin/JosefinSansFont-master), with Reserved Font Name "Josefin Sans".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+123
View File
@@ -0,0 +1,123 @@
# clide — licensing manifest.
#
# Three sections:
# - `self:` — clide's own license (MIT). Rendered first in the About
# screen so the user knows what they're running before they see
# the list of things it depends on.
# - `dependencies:` — third-party artefacts that SHIP in the clide
# binary: fonts, runtime Dart packages, native supporter tools,
# bundled data. The About screen renders these verbatim.
# - `dev_dependencies:` — build-time-only tooling: test runners,
# mocks, lints, golden-test harness. These never enter the shipped
# binary, so the About screen does not render them, but they're
# tracked here for audit completeness alongside the real list.
#
# Adding a new runtime dependency is a two-step commit:
# 1. Add the dependency itself (font file, `pubspec.yaml` entry, etc.)
# 2. Add the entry under `dependencies:` below in the same commit, so
# the About screen stays accurate and the prefer-zero-deps
# guardrail is enforced by the obvious visible consequence: an
# extra row in the list.
#
# Adding a new dev-only dependency is the same two-step commit, just
# under `dev_dependencies:` instead. The guardrail still applies —
# dev deps are supply-chain risk too — but they don't pollute the
# user-facing About screen.
#
# See CLAUDE.md "Dependencies & supply chain" for the full rule, and
# `decisions/tooling.md` for the D-record that pins it.
schema_version: 1
# clide itself. Listed first so the About screen can render "You're
# looking at clide vX, MIT-licensed" at the top of the panel before
# the bundled-dependency list underneath. The root LICENSE is the
# authoritative copy; `app/assets/LICENSE` is a mirror bundled so the
# About screen can read it at runtime.
self:
name: clide
version: "2.0.0-dev"
homepage: https://git.schweitz.net/jpmschweitzer/clide
license: MIT
license_file: assets/LICENSE
copyright: "© 2026 Jeroen Schweitzer"
# Runtime dependencies — ship in the clide binary.
dependencies:
- name: JetBrains Mono
kind: font
version: "2.304"
homepage: https://github.com/JetBrains/JetBrainsMono
license: OFL-1.1
license_file: assets/fonts/jetbrains_mono/OFL.txt
purpose: >-
Monospace face for terminal panes, diff views, code editors, and
any other monospace surface.
weights_bundled: [Regular, Italic, Bold, BoldItalic]
- name: Josefin Sans
kind: font
version: "variable"
homepage: https://fonts.google.com/specimen/Josefin+Sans
license: OFL-1.1
license_file: assets/fonts/josefin_sans/OFL.txt
purpose: >-
Application UI face. Default weight Light (300); full 100-700
range available via the variable-font weight axis.
weights_bundled: [VariableFont, Italic-VariableFont]
- name: yaml
kind: dart-package
version: "3.1.3"
homepage: https://pub.dev/packages/yaml
license: MIT
purpose: >-
YAML parser for theme files and extension manifests. The one
justified exception to prefer-zero-deps; Dart-team maintained.
# Build-time-only dependencies — test runners, mocks, lints. Tracked
# here for audit completeness; NOT rendered in the About screen.
dev_dependencies:
- name: mocktail
kind: dart-package
version: "1.0.4"
homepage: https://pub.dev/packages/mocktail
license: MIT
purpose: >-
Mocks at IO / IPC boundaries. ChangeNotifier facades use
hand-rolled fakes instead of mocks (D-025).
- name: alchemist
kind: dart-package
version: "0.12.1"
homepage: https://pub.dev/packages/alchemist
license: MIT
purpose: >-
Golden-test framework; Ahem-font rendering for cross-platform
pixel stability on widget primitives (D-024).
- name: flutter_lints
kind: dart-package
version: "6.0.0"
homepage: https://pub.dev/packages/flutter_lints
license: BSD-3-Clause
purpose: Flutter-team-recommended analyzer lint set (app/).
- name: lints
kind: dart-package
version: "5.0.0"
homepage: https://pub.dev/packages/lints
license: BSD-3-Clause
purpose: >-
Dart-team-recommended analyzer lint set for the Flutter-free
core package at the repo root.
- name: test
kind: dart-package
version: "1.25.8"
homepage: https://pub.dev/packages/test
license: BSD-3-Clause
purpose: >-
Dart test runner for the core package (Flutter-free; the app
uses flutter_test from the Flutter SDK for widget + golden
coverage).
+3 -6
View File
@@ -70,12 +70,9 @@ class _RootShellState extends State<_RootShell> {
style: TextStyle(
color: tokens.globalForeground,
fontSize: 13,
fontFamilyFallback: const [
'Inter',
'Helvetica',
'Arial',
'sans-serif',
],
fontWeight: clideUiDefaultWeight,
fontFamily: clideUiFamily,
fontFamilyFallback: clideUiFamilyFallback,
),
child: KeyboardListener(
focusNode: _keyFocus,
+15 -4
View File
@@ -1,14 +1,22 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
/// Theme-aware Text. Defaults pull from the global foreground token.
/// Theme-aware Text. Defaults pull from the global foreground token
/// and [clideUiDefaultWeight].
///
/// The UI font family is deliberately **not** set on this widget — it
/// inherits from the ambient `DefaultTextStyle` which `_AppRoot`
/// provides ([clideUiFamily] in real runs). Goldens rely on Alchemist
/// injecting Ahem for deterministic metrics; hard-coding a family here
/// would override that and break pixel determinism per D-024.
class ClideText extends StatelessWidget {
const ClideText(
this.data, {
super.key,
this.color,
this.fontSize = 13,
this.fontWeight = FontWeight.w400,
this.fontWeight,
this.muted = false,
this.maxLines,
this.overflow,
@@ -18,7 +26,7 @@ class ClideText extends StatelessWidget {
final String data;
final Color? color;
final double fontSize;
final FontWeight fontWeight;
final FontWeight? fontWeight;
final bool muted;
final int? maxLines;
final TextOverflow? overflow;
@@ -37,8 +45,11 @@ class ClideText extends StatelessWidget {
style: TextStyle(
color: resolved,
fontSize: fontSize,
// Null means inherit from the ambient DefaultTextStyle —
// _AppRoot installs clideUiDefaultWeight there; goldens get
// whatever Alchemist injects. Passing an explicit weight (e.g.
// FontWeight.bold) still wins.
fontWeight: fontWeight,
fontFamilyFallback: const ['Inter', 'Helvetica', 'Arial', 'sans-serif'],
),
);
}
+67
View File
@@ -0,0 +1,67 @@
/// Typography constants shared across widgets.
///
/// Two bundled families:
///
/// - [clideUiFamily] — Josefin Sans, the application-wide UI face.
/// Shipped as a variable font (weights 100-700) + italic companion;
/// default weight is [clideUiDefaultWeight] (Light / `w300`).
/// - [clideMonoFamily] — JetBrains Mono, for terminal panes, diff
/// views, code editors, and any other monospace surface.
///
/// Fallback chains exist for web builds + harnesses where the bundled
/// asset isn't picked up (rare, but possible during `flutter test` if
/// asset fonts aren't declared in the harness).
library;
import 'package:flutter/widgets.dart' show FontWeight;
// ---------------------------------------------------------------------------
// UI face — Josefin Sans
// ---------------------------------------------------------------------------
/// The bundled application UI family. Always resolved first.
const String clideUiFamily = 'JosefinSans';
/// Default weight for UI text. Josefin Sans reads well at Light; the
/// rest of the design adjusts contrast and size to stay legible.
const FontWeight clideUiDefaultWeight = FontWeight.w300;
/// System fallback chain for the UI face. Sans-serif humanist faces
/// that sit close to Josefin's proportions, ordered by platform.
const List<String> clideUiFamilyFallback = [
// User system install of Josefin, if any.
'Josefin Sans',
// Platform humanist sans defaults.
'Inter',
'Helvetica Neue',
'Helvetica',
'Arial',
'sans-serif',
];
// ---------------------------------------------------------------------------
// Monospace face — JetBrains Mono
// ---------------------------------------------------------------------------
/// The bundled monospace family. Always resolved first.
const String clideMonoFamily = 'JetBrainsMono';
/// System fallback chain. Ordered by platform prevalence + quality of
/// programming-ligature / box-drawing coverage.
const List<String> clideMonoFamilyFallback = [
// macOS
'SF Mono',
'Menlo',
'Monaco',
// Linux — user system install under the canonical PostScript name
'JetBrains Mono',
'Fira Code',
'Hack',
'DejaVu Sans Mono',
'Liberation Mono',
// Windows
'Cascadia Code',
'Consolas',
// Last resort
'monospace',
];
+1
View File
@@ -13,6 +13,7 @@ export 'src/clide_surface.dart';
export 'src/clide_tab_bar.dart';
export 'src/clide_text.dart';
export 'src/clide_tooltip.dart';
export 'src/typography.dart';
export 'src/icons/check.dart';
export 'src/icons/chevron.dart';
export 'src/icons/dot.dart';
+37
View File
@@ -53,3 +53,40 @@ flutter:
assets:
- lib/kernel/src/theme/themes/
- lib/kernel/src/i18n/catalog/
# Licensing — read by the About screen. `licenses.yaml` is the
# manifest (self + runtime deps + dev deps); each `license_file`
# pointer must also be a bundled asset so the About screen can
# display each dependency's full license text at runtime.
# Root LICENSE is mirrored into assets/LICENSE for the same reason
# (root paths can't be referenced from a Flutter package's assets).
# See CLAUDE.md "Dependencies & supply chain" and D-042.
- assets/licenses.yaml
- assets/LICENSE
- assets/fonts/jetbrains_mono/OFL.txt
- assets/fonts/josefin_sans/OFL.txt
# Bundled fonts. OFL-licensed; license files live alongside each.
#
# JosefinSans — UI default. Variable font covers weights 100-700
# plus an italic axis companion; we default to Light (300) per the
# project's aesthetic direction.
#
# JetBrainsMono — terminal / code surfaces. Static weights keep
# rendering predictable across xterm.dart, goldens, and CI.
fonts:
- family: JosefinSans
fonts:
- asset: assets/fonts/josefin_sans/JosefinSans-VariableFont.ttf
- asset: assets/fonts/josefin_sans/JosefinSans-Italic-VariableFont.ttf
style: italic
- family: JetBrainsMono
fonts:
- asset: assets/fonts/jetbrains_mono/JetBrainsMono-Regular.ttf
- asset: assets/fonts/jetbrains_mono/JetBrainsMono-Italic.ttf
style: italic
- asset: assets/fonts/jetbrains_mono/JetBrainsMono-Bold.ttf
weight: 700
- asset: assets/fonts/jetbrains_mono/JetBrainsMono-BoldItalic.ttf
weight: 700
style: italic
+7
View File
@@ -18,6 +18,13 @@ Toolchain, supply chain, CI, ignore strategy.
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
- **Raised by:** 2026-04-21 planning.
### D-042: Dependencies documented in `licenses.yaml`
- **Date:** 2026-04-22
- **Decision:** `app/assets/licenses.yaml` has three sections: `self:` (clide's MIT license, rendered first in the About screen so the user knows what they're running), `dependencies:` (third-party artefacts that **ship in the binary** — fonts, runtime Dart packages, native supporter tools, bundled data), and `dev_dependencies:` (build-time-only tooling — test runners, mocks, lints, golden harness — tracked for audit but **not rendered** in the About screen because they don't reach the user). Each entry has name, kind, version, homepage, license identifier, and a one-line purpose; runtime entries also carry a `license_file:` pointer to the bundled license text so the About screen can display it verbatim. Adding any dependency is a two-step commit: add the artefact **and** the corresponding `licenses.yaml` entry in the same changeset, under the correct section.
- **Rationale:** Complements [D-031](#d-031-prefer-zero-deps-exact-pin). Prefer-zero-deps is a *budget*; `licenses.yaml` is the *visible consequence*. An extra row in the About screen is a review-time signal that the shipped-binary surface grew. Splitting dev deps out keeps the user-facing list small and honest — a test framework is not something the user needs to see in About — while still documenting every supply-chain input for audit completeness. The runtime entries discharge the redistribution obligations bundled licenses impose (OFL, MIT, BSD all require preserving the license text alongside the binary) without ad-hoc NOTICE files.
- **Cost:** One extra edit per dep. Zero tolerance for drift — an un-listed dep is a contributor-visible bug. Until the About screen lands at Tier 6, `licenses.yaml` is accurate but not rendered; the discipline applies from now regardless so Tier 6 inherits a clean list.
- **Raised by:** 2026-04-22 planning (user-directed best practice).
### D-033: Golden-output ignore pattern — `coverage.*` excludes output, not scripts
- **Date:** 2026-04-21
- **Decision:** `.gitignore` excludes `coverage.*` (the lcov output files from `flutter test --coverage`). Coverage-related scripts are named `ci/test_coverage.sh` (not `ci/coverage.sh`) to stay outside the pattern.
+27
View File
@@ -0,0 +1,27 @@
# ptyc — PTY-spawn helper for clide.
#
# Single source file, libc only. No configure step. Builds everywhere a
# POSIX-y C compiler + unix sockets exist.
CC ?= cc
CFLAGS ?= -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2
LDFLAGS ?=
BIN := bin/ptyc
.PHONY: all
all: $(BIN)
$(BIN): ptyc.c | bin
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
bin:
mkdir -p bin
.PHONY: test
test: $(BIN)
./test_ptyc.sh
.PHONY: clean
clean:
rm -rf bin *.o
+127
View File
@@ -0,0 +1,127 @@
# ptyc
Small POSIX helper that spawns a child process under a PTY and hands
the master fd back to its caller. Language-agnostic; usable from any
program that can fork a subprocess and receive a file descriptor over a
unix socket.
Clide uses it for every PTY it owns (terminal panes, Claude sessions,
tmux wrappers, LSP servers, debug adapters). See
[`D-005`](../decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer)
for the architectural rationale; ptyc is a peer of
[`pql`](https://github.com/postmeridiem/pql), not a clide subsystem.
## Build
```sh
make # produces bin/ptyc
make test # runs test_ptyc.sh against the built binary
make clean
```
No third-party dependencies. `CC`, `CFLAGS`, and `LDFLAGS` are
overrideable in the usual way.
## Wire contract
ptyc is a one-shot helper. The caller:
1. Creates a `socketpair(AF_UNIX, SOCK_STREAM, 0)`.
2. Launches `ptyc` as a subprocess, passing one end of the socket to
the child as **file descriptor 3** (the default) or whatever fd is
given in the `PTYC_SOCK_FD` environment variable. The other end of
the socket stays with the caller. The env-var override exists
because some language runtimes (Python's `subprocess` with
`stdout=PIPE`, for example) shuffle their own pipe fds through the
low numbers and it's cheaper for the caller to pick a higher fd
than to dup2 it down.
3. Writes the request as **JSON on stdin** and closes stdin (EOF
signals end of request).
4. Receives the master PTY fd over the socket via **`SCM_RIGHTS`**
ancillary data (with a single-byte `'x'` payload so the receiver
knows when to `recvmsg`).
5. Reads the success response from stdout (single line of JSON) and
reaps the exited `ptyc` process.
### Request
JSON object on stdin. All fields optional except `argv`.
```json
{
"argv": ["bash", "-l"],
"cwd": "/home/me/work",
"env": {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
"cols": 80,
"rows": 24
}
```
- `argv` — required, non-empty array of strings. `argv[0]` is resolved
via `PATH`.
- `cwd` — optional. If omitted, the child inherits ptyc's cwd.
- `env` — optional object. If present, the child's environment is
**replaced** with exactly the keys given (ptyc does `clearenv()` and
then `putenv` per entry). If absent, the child inherits ptyc's
environment. This is a deliberate choice: the daemon is expected to
build the env it wants, not rely on a merge.
- `cols`, `rows` — optional. Default `80` × `24`. Applied via
`TIOCSWINSZ` before fork.
### Success response (stdout)
```json
{"ok":true,"pid":12345}
```
One line, trailing newline. The master PTY fd is already on the socket
by the time stdout is written. `pid` is the spawned child's PID — the
caller is responsible for `waitpid`'ing it when appropriate.
### Error response (stderr)
```json
{"ok":false,"error":"exec: No such file or directory","errno":2}
```
Written on stderr. No fd is sent. ptyc exits with a non-zero code.
### Exit codes
| Code | Meaning |
|------|---------|
| `0` | Success — fd sent, success response on stdout. |
| `1` | Bad request — JSON parse error, missing `argv`, bad field values. |
| `2` | Syscall failed — fork, exec, PTY open, `sendmsg`, etc. Check `errno` in the response. |
## Limits
Compile-time caps, deliberately small:
- `MAX_ARGV` = 64 entries
- `MAX_ENV` = 256 entries
- `MAX_INPUT` = 64 KiB request size
These are far above what any reasonable pane invocation needs; if you
hit them you're holding ptyc wrong. Edit the `#define`s in `ptyc.c` and
rebuild.
## Security notes
- The JSON parser is scoped to the shape above. It rejects anything
else. Strings support the standard `\"` `\\` `\/` `\b` `\f` `\n`
`\r` `\t` escapes and ASCII-range `\uXXXX`. Non-ASCII Unicode escapes
(and surrogate pairs) are rejected — the daemon is expected to emit
raw UTF-8 bytes.
- Input is trusted (daemon is local, same user). ptyc does not sanitise
argv or env beyond format-level checks — if the daemon asks ptyc to
exec `rm`, ptyc execs `rm`.
- ptyc does not `setuid` or `setgid`. It runs as the invoking user.
## Session persistence
ptyc is stateless and one-shot. Session persistence (survive app
restart) is the **caller's** concern. Clide achieves it by spawning
ptyc with `tmux new-session -A -s <name> -- <cmd>` for Claude panes;
tmux handles the persistence layer and ptyc just spawns tmux. See
`D-041` (Claude panes — one primary per repo, tmux-backed).
+476
View File
@@ -0,0 +1,476 @@
/*
* ptyc — spawn a child under a PTY, hand the master fd back.
*
* Wire contract (documented in README.md):
* stdin : JSON request {"argv":[...],"cwd":"...","env":{...},"cols":N,"rows":N}
* stdout : JSON response {"ok":true,"pid":N} on success
* stderr : JSON diagnostic {"ok":false,"error":"...","errno":N} on failure
* fd 3 : unix-domain socket; master fd is sent over SCM_RIGHTS on success
*
* Exit codes:
* 0 success
* 1 bad request (JSON parse error, missing field, bad value)
* 2 syscall failed (fork/exec/pty/socket)
*
* Dependencies: libc only. POSIX APIs where available (posix_openpt,
* grantpt, unlockpt, ptsname). Single-threaded, one-shot, ~300 LOC.
*/
#define _POSIX_C_SOURCE 200809L
#define _XOPEN_SOURCE 600
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
/* POSIX exposes `environ` but requires an explicit declaration. We
* set it to NULL in the child to replace the inherited environment
* when the caller supplied one — `clearenv()` is GNU-only, not POSIX. */
extern char **environ;
/* Discard write() result without tripping warn_unused_result. We only
* call this on the exec-failure pipe in the child immediately before
* _exit(127); the parent either receives the bytes or notices EOF via
* the CLOEXEC pipe. Nothing useful to do with the return value. */
static void report_errno(int fd, int e) {
ssize_t r = write(fd, &e, sizeof(e));
(void)r;
}
#define MAX_ARGV 64
#define MAX_ENV 256
#define MAX_INPUT (64 * 1024)
/* -------------------------------------------------------------------- */
/* error reporting */
/* -------------------------------------------------------------------- */
static void die_bad_request(const char *msg) {
fprintf(stderr, "{\"ok\":false,\"error\":\"%s\",\"errno\":0}\n", msg);
exit(1);
}
static void die_syscall(const char *msg) {
int e = errno;
/* Avoid quoting edge cases: strerror results don't contain quotes on
* any platform we care about; if this ever bites us we'll escape. */
fprintf(stderr, "{\"ok\":false,\"error\":\"%s: %s\",\"errno\":%d}\n", msg,
strerror(e), e);
exit(2);
}
/* -------------------------------------------------------------------- */
/* minimal JSON parser */
/* */
/* Scoped to exactly the shape we accept. String escapes supported for */
/* the subset we emit (\" \\ \n \r \t \b \f \/ and \uXXXX for ASCII). */
/* Surrogate pairs, nested arrays, and non-string numbers-as-keys are */
/* not supported — the daemon never emits them. */
/* -------------------------------------------------------------------- */
typedef struct {
const char *src;
size_t len;
size_t pos;
} Parser;
static void p_skip_ws(Parser *p) {
while (p->pos < p->len) {
char c = p->src[p->pos];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
p->pos++;
} else {
break;
}
}
}
static int p_peek(Parser *p) {
p_skip_ws(p);
return p->pos < p->len ? (unsigned char)p->src[p->pos] : -1;
}
static int p_expect(Parser *p, char c) {
if (p_peek(p) != (unsigned char)c) return 0;
p->pos++;
return 1;
}
/* Parse a JSON string into a freshly-allocated NUL-terminated buffer. */
static char *p_string(Parser *p) {
if (p_peek(p) != '"') return NULL;
p->pos++;
size_t start = p->pos;
/* First pass: find end and compute output length. */
size_t out_len = 0;
while (p->pos < p->len && p->src[p->pos] != '"') {
if (p->src[p->pos] == '\\') {
if (p->pos + 1 >= p->len) return NULL;
char esc = p->src[p->pos + 1];
if (esc == 'u') {
if (p->pos + 5 >= p->len) return NULL;
/* We only accept ASCII in \uXXXX. */
for (int i = 2; i < 6; i++) {
if (!isxdigit((unsigned char)p->src[p->pos + i])) return NULL;
}
p->pos += 6;
} else {
p->pos += 2;
}
out_len++;
} else {
p->pos++;
out_len++;
}
}
if (p->pos >= p->len || p->src[p->pos] != '"') return NULL;
size_t end = p->pos;
p->pos++; /* consume closing quote */
char *out = malloc(out_len + 1);
if (!out) return NULL;
size_t j = 0;
for (size_t i = start; i < end;) {
if (p->src[i] == '\\') {
char esc = p->src[i + 1];
switch (esc) {
case '"': out[j++] = '"'; i += 2; break;
case '\\': out[j++] = '\\'; i += 2; break;
case '/': out[j++] = '/'; i += 2; break;
case 'b': out[j++] = '\b'; i += 2; break;
case 'f': out[j++] = '\f'; i += 2; break;
case 'n': out[j++] = '\n'; i += 2; break;
case 'r': out[j++] = '\r'; i += 2; break;
case 't': out[j++] = '\t'; i += 2; break;
case 'u': {
unsigned int cp = 0;
for (int k = 0; k < 4; k++) {
char h = p->src[i + 2 + k];
cp <<= 4;
if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0');
else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10);
}
/* ASCII-range only. Anything else is a request-format bug. */
if (cp > 0x7f) { free(out); return NULL; }
out[j++] = (char)cp;
i += 6;
break;
}
default: free(out); return NULL;
}
} else {
out[j++] = p->src[i++];
}
}
out[j] = '\0';
return out;
}
static int p_int(Parser *p, long *out) {
p_skip_ws(p);
size_t start = p->pos;
if (p->pos < p->len && (p->src[p->pos] == '-' || p->src[p->pos] == '+'))
p->pos++;
int digits = 0;
while (p->pos < p->len && isdigit((unsigned char)p->src[p->pos])) {
p->pos++;
digits++;
}
if (!digits) {
p->pos = start;
return 0;
}
char buf[32];
size_t n = p->pos - start;
if (n >= sizeof(buf)) return 0;
memcpy(buf, p->src + start, n);
buf[n] = '\0';
*out = strtol(buf, NULL, 10);
return 1;
}
/* -------------------------------------------------------------------- */
/* request */
/* -------------------------------------------------------------------- */
typedef struct {
char *argv[MAX_ARGV + 1]; /* NULL-terminated */
int argc;
char *cwd; /* optional, NULL means inherit */
char *env[MAX_ENV + 1]; /* each "KEY=VAL" */
int envc;
int cols;
int rows;
} Request;
static void req_init(Request *r) {
memset(r, 0, sizeof(*r));
r->cols = 80;
r->rows = 24;
}
static void req_free(Request *r) {
for (int i = 0; i < r->argc; i++) free(r->argv[i]);
for (int i = 0; i < r->envc; i++) free(r->env[i]);
free(r->cwd);
}
static void parse_argv(Parser *p, Request *r) {
if (!p_expect(p, '[')) die_bad_request("argv must be an array");
if (p_peek(p) == ']') { p->pos++; return; }
for (;;) {
if (r->argc >= MAX_ARGV) die_bad_request("argv too long");
char *s = p_string(p);
if (!s) die_bad_request("argv element must be a string");
r->argv[r->argc++] = s;
if (p_expect(p, ',')) continue;
if (p_expect(p, ']')) break;
die_bad_request("malformed argv array");
}
}
static void parse_env(Parser *p, Request *r) {
if (!p_expect(p, '{')) die_bad_request("env must be an object");
if (p_peek(p) == '}') { p->pos++; return; }
for (;;) {
if (r->envc >= MAX_ENV) die_bad_request("env too large");
char *k = p_string(p);
if (!k) die_bad_request("env key must be a string");
if (!p_expect(p, ':')) { free(k); die_bad_request("env missing ':'"); }
char *v = p_string(p);
if (!v) { free(k); die_bad_request("env value must be a string"); }
size_t kl = strlen(k), vl = strlen(v);
char *kv = malloc(kl + 1 + vl + 1);
if (!kv) { free(k); free(v); die_syscall("malloc"); }
memcpy(kv, k, kl);
kv[kl] = '=';
memcpy(kv + kl + 1, v, vl);
kv[kl + 1 + vl] = '\0';
free(k); free(v);
r->env[r->envc++] = kv;
if (p_expect(p, ',')) continue;
if (p_expect(p, '}')) break;
die_bad_request("malformed env object");
}
}
static void parse_request(const char *src, size_t len, Request *r) {
Parser p = { .src = src, .len = len, .pos = 0 };
if (!p_expect(&p, '{')) die_bad_request("top-level must be an object");
if (p_peek(&p) == '}') { p.pos++; goto done; }
for (;;) {
char *key = p_string(&p);
if (!key) die_bad_request("key must be a string");
if (!p_expect(&p, ':')) { free(key); die_bad_request("missing ':'"); }
if (strcmp(key, "argv") == 0) {
parse_argv(&p, r);
} else if (strcmp(key, "cwd") == 0) {
r->cwd = p_string(&p);
if (!r->cwd) { free(key); die_bad_request("cwd must be a string"); }
} else if (strcmp(key, "env") == 0) {
parse_env(&p, r);
} else if (strcmp(key, "cols") == 0) {
long v; if (!p_int(&p, &v)) { free(key); die_bad_request("cols must be an integer"); }
if (v < 1 || v > 65535) { free(key); die_bad_request("cols out of range"); }
r->cols = (int)v;
} else if (strcmp(key, "rows") == 0) {
long v; if (!p_int(&p, &v)) { free(key); die_bad_request("rows must be an integer"); }
if (v < 1 || v > 65535) { free(key); die_bad_request("rows out of range"); }
r->rows = (int)v;
} else {
free(key);
die_bad_request("unknown key");
}
free(key);
if (p_expect(&p, ',')) continue;
if (p_expect(&p, '}')) break;
die_bad_request("malformed object");
}
done:
if (r->argc == 0) die_bad_request("argv is required and non-empty");
r->argv[r->argc] = NULL;
r->env[r->envc] = NULL;
}
/* -------------------------------------------------------------------- */
/* read stdin into a bounded buffer */
/* -------------------------------------------------------------------- */
static char *slurp_stdin(size_t *out_len) {
char *buf = malloc(MAX_INPUT);
if (!buf) die_syscall("malloc");
size_t n = 0;
while (n < MAX_INPUT) {
ssize_t r = read(0, buf + n, MAX_INPUT - n);
if (r == 0) break;
if (r < 0) {
if (errno == EINTR) continue;
die_syscall("read(stdin)");
}
n += (size_t)r;
}
if (n == MAX_INPUT) die_bad_request("request too large");
*out_len = n;
return buf;
}
/* -------------------------------------------------------------------- */
/* PTY open + spawn */
/* -------------------------------------------------------------------- */
static void send_fd(int sock, int fd) {
/* sendmsg with SCM_RIGHTS; one byte payload so receiver knows to read. */
char byte = 'x';
struct iovec iov = { .iov_base = &byte, .iov_len = 1 };
union {
struct cmsghdr hdr;
char buf[CMSG_SPACE(sizeof(int))];
} cbuf;
memset(&cbuf, 0, sizeof(cbuf));
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cbuf.buf;
msg.msg_controllen = sizeof(cbuf.buf);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(cmsg), &fd, sizeof(int));
for (;;) {
ssize_t r = sendmsg(sock, &msg, 0);
if (r < 0 && errno == EINTR) continue;
if (r < 0) die_syscall("sendmsg(fd)");
break;
}
}
int main(void) {
Request req;
req_init(&req);
size_t in_len = 0;
char *in = slurp_stdin(&in_len);
parse_request(in, in_len, &req);
free(in);
/* 1. open master */
int master = posix_openpt(O_RDWR | O_NOCTTY);
if (master < 0) die_syscall("posix_openpt");
if (grantpt(master) < 0) die_syscall("grantpt");
if (unlockpt(master) < 0) die_syscall("unlockpt");
/* 2. open slave (ptsname is POSIX; we're single-threaded) */
const char *slave_path = ptsname(master);
if (!slave_path) die_syscall("ptsname");
int slave = open(slave_path, O_RDWR | O_NOCTTY);
if (slave < 0) die_syscall("open(slave)");
/* 3. apply window size */
struct winsize ws = {0};
ws.ws_col = (unsigned short)req.cols;
ws.ws_row = (unsigned short)req.rows;
if (ioctl(master, TIOCSWINSZ, &ws) < 0) die_syscall("ioctl(TIOCSWINSZ)");
/* 4. exec-failure-reporting pipe (CLOEXEC so it auto-closes on success) */
int ef[2];
if (pipe(ef) < 0) die_syscall("pipe");
if (fcntl(ef[1], F_SETFD, FD_CLOEXEC) < 0) die_syscall("fcntl(FD_CLOEXEC)");
pid_t pid = fork();
if (pid < 0) die_syscall("fork");
if (pid == 0) {
/* ---- child ---- */
close(master);
close(ef[0]);
if (setsid() < 0) { report_errno(ef[1], errno); _exit(127); }
#ifdef TIOCSCTTY
if (ioctl(slave, TIOCSCTTY, 0) < 0) { report_errno(ef[1], errno); _exit(127); }
#endif
if (dup2(slave, 0) < 0 || dup2(slave, 1) < 0 || dup2(slave, 2) < 0) {
report_errno(ef[1], errno);
_exit(127);
}
if (slave > 2) close(slave);
if (req.cwd && chdir(req.cwd) < 0) { report_errno(ef[1], errno); _exit(127); }
/* Replace the environment if the caller supplied one; otherwise
* inherit. Daemon is expected to build the env it wants — this is
* not a "merge" API. */
if (req.envc > 0) {
environ = NULL;
for (int i = 0; i < req.envc; i++) {
if (putenv(req.env[i]) != 0) { report_errno(ef[1], errno); _exit(127); }
}
}
execvp(req.argv[0], req.argv);
/* execvp returned → failure */
report_errno(ef[1], errno);
_exit(127);
}
/* ---- parent ---- */
close(slave);
close(ef[1]);
int child_errno = 0;
ssize_t rr;
for (;;) {
rr = read(ef[0], &child_errno, sizeof(child_errno));
if (rr < 0 && errno == EINTR) continue;
break;
}
close(ef[0]);
if (rr == (ssize_t)sizeof(child_errno)) {
/* exec failed in child; reap it so we don't leak a zombie. */
int st;
(void)waitpid(pid, &st, 0);
close(master);
errno = child_errno;
die_syscall("execvp");
}
/* rr == 0: pipe closed via CLOEXEC on successful exec. */
/* Hand the master fd back to the parent caller over a unix socket.
* Default fd is 3; callers that can't reliably place the socket at
* fd 3 (e.g. Python's subprocess with stdout=PIPE shifts pipe fds
* around fd 3) can override via PTYC_SOCK_FD. */
int sock_fd = 3;
const char *sock_env = getenv("PTYC_SOCK_FD");
if (sock_env && *sock_env) {
char *endp = NULL;
long v = strtol(sock_env, &endp, 10);
if (!endp || *endp != '\0' || v < 0 || v > 65535)
die_bad_request("PTYC_SOCK_FD must be a non-negative integer");
sock_fd = (int)v;
}
send_fd(sock_fd, master);
close(master);
/* Emit success response on stdout and exit. */
printf("{\"ok\":true,\"pid\":%ld}\n", (long)pid);
fflush(stdout);
req_free(&req);
return 0;
}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# Smoke test for ptyc. Uses python3 for the SCM_RIGHTS fd receive dance.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
BIN="$HERE/bin/ptyc"
if [[ ! -x "$BIN" ]]; then
echo "test: bin/ptyc not built — run 'make' first" >&2
exit 2
fi
python3 - "$BIN" <<'PY'
import errno
import json
import os
import socket
import subprocess
import sys
import time
ptyc = sys.argv[1]
def spawn(req):
"""Launch ptyc, pipe the request in, receive fd + response."""
sock_parent, sock_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
child_fd = sock_child.fileno()
env = {**os.environ, "PTYC_SOCK_FD": str(child_fd)}
try:
p = subprocess.Popen(
[ptyc],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
pass_fds=(child_fd,),
env=env,
)
finally:
sock_child.close()
p.stdin.write(json.dumps(req).encode())
p.stdin.close()
fd = None
try:
msg, ancdata, _flags, _addr = sock_parent.recvmsg(1, socket.CMSG_SPACE(4))
for cmsg_level, cmsg_type, cmsg_data in ancdata:
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
fd = int.from_bytes(cmsg_data[:4], "little")
break
except OSError:
pass
sock_parent.close()
stdout = p.stdout.read().decode()
stderr = p.stderr.read().decode()
code = p.wait()
return code, stdout, stderr, fd
def expect_ok(req, reads_substr=None):
code, out, err, fd = spawn(req)
assert code == 0, f"exit={code}, stderr={err!r}"
j = json.loads(out)
assert j["ok"] is True, out
assert j["pid"] > 0, out
assert fd is not None and fd >= 0, "no fd received"
pid = j["pid"]
try:
if reads_substr is not None:
chunks = []
deadline = time.time() + 5.0
while time.time() < deadline:
try:
data = os.read(fd, 4096)
except OSError as e:
if e.errno in (errno.EIO,): # child exited, PTY EOF on Linux
break
raise
if not data:
break
chunks.append(data.decode(errors="replace"))
if reads_substr in "".join(chunks):
break
got = "".join(chunks)
assert reads_substr in got, f"expected {reads_substr!r} in {got!r}"
finally:
os.close(fd)
# Child should exit on its own after producing its output for an
# `echo`; give it a moment, then reap.
try:
for _ in range(20):
rpid, _ = os.waitpid(pid, os.WNOHANG)
if rpid == pid:
break
time.sleep(0.05)
else:
os.kill(pid, 9)
os.waitpid(pid, 0)
except ChildProcessError:
pass
def expect_err(req, match_fragment):
code, out, err, fd = spawn(req)
assert code != 0, f"expected failure, got ok: {out!r}"
assert fd is None, "error path must not send an fd"
j = json.loads(err)
assert j["ok"] is False, err
assert match_fragment in j["error"], f"{match_fragment!r} not in {j['error']!r}"
# 1. Happy path: echo prints and exits cleanly.
expect_ok({"argv": ["/bin/echo", "hello-ptyc"]}, reads_substr="hello-ptyc")
# 2. env replacement works — child sees exactly the keys we pass.
expect_ok(
{"argv": ["/usr/bin/env"], "env": {"FOO": "bar", "PATH": "/usr/bin:/bin"}},
reads_substr="FOO=bar",
)
# 3. cwd respected.
expect_ok({"argv": ["/bin/sh", "-c", "pwd"], "cwd": "/tmp"}, reads_substr="/tmp")
# 4. window size propagates (stty reports it).
expect_ok(
{"argv": ["/bin/sh", "-c", "stty size"], "cols": 132, "rows": 42},
reads_substr="42 132",
)
# 5. Bad request: missing argv.
expect_err({}, "argv is required")
# 6. Bad request: argv not an array.
expect_err({"argv": "bash"}, "argv must be an array")
# 7. exec failure reported on error channel.
expect_err({"argv": ["/does/not/exist/nope"]}, "execvp")
# 8. Unknown key rejected.
expect_err({"argv": ["true"], "wat": 1}, "unknown key")
print("ptyc: all smoke tests passed")
PY